Component librarylocal catalog
Back to catalog

Image comparison slider

Two versions of one scene in a single frame, with a divider you drag, tap or arrow across to reveal each.

mediasliderinteractiverange

Open preview in a new tab

Use this component

Three ways to get Image comparison slider into one of your own projects, all pre-filled with this component's name and library page.

Start here

Component reference

Paste it into a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving the branding and content open.

Read it first
“Image comparison slider” from my Astro Component Library:
https://astro.baysixmedia.com/components/image-comparison-slider/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

ChatGPT brief

Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.

Read it first
Help me add this component to one of my Astro websites:

Component: Image comparison slider
Library page: https://astro.baysixmedia.com/components/image-comparison-slider

I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]

Place it:
[ENTER LOCATION OR SELECTOR]

Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]

Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.

Placement prompt

Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.

Read it first
Add Image comparison slider from my Astro Component Library to this Astro project.

Library page:
https://astro.baysixmedia.com/components/image-comparison-slider

First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

src/components/library/ImageComparisonSlider.astro

This component is not one file on its own

  • assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/image-comparison/ridge-ungraded.webp, public/image-comparison/ridge-graded.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * ImageComparisonSlider — two images of the same scene stacked in one frame,
 * with a draggable divider that reveals one over the other.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages:
 * drop it into any Astro project and render it.
 *
 * Two decisions carry most of the weight:
 *
 * 1. The control is a real `<input type="range">` stretched over the frame at
 *    zero opacity. Pointer, touch, click-to-jump, Arrow keys, Home/End,
 *    PageUp/PageDown and the focus ring all come from the browser rather than
 *    from event handlers, and it lands in the tab order and the accessibility
 *    tree as a slider without any ARIA role being invented for it.
 *
 * 2. The reveal is a `clip-path` on a full-size layer, not a width on a
 *    shrinking one. Both images stay exactly the same size and position at
 *    every divider position, so they cannot drift out of alignment — which is
 *    the one thing this component has to get right.
 *
 * The script does one job: copy the range's value into a custom property.
 */

interface Props {
	/** URL of the image revealed on the left. Omit for a drawn placeholder. */
	beforeImage?: string;
	/** Alternative text for the before image. */
	beforeAlt?: string;
	/** URL of the image revealed on the right. Omit for a drawn placeholder. */
	afterImage?: string;
	/** Alternative text for the after image. */
	afterAlt?: string;
	/** Caption over the left image. */
	beforeLabel?: string;
	/** Caption over the right image. */
	afterLabel?: string;
	/** Where the divider starts, 0–100. */
	initialPosition?: number;
	/** Frame aspect ratio, e.g. "4 / 3" or "16 / 9". */
	aspectRatio?: string;
	/**
	 * Caps the frame width and centres it. The component is fluid by default
	 * so the parent decides; set this when it is placed directly on a page.
	 */
	maxWidth?: string;
	/** `object-position` applied to both images, so they crop identically. */
	objectPosition?: string;
	/**
	 * Draw the two captions over the images. On by default, so the frame says
	 * which side is which without the reader having to work it out. Pass
	 * `false` for a bare frame.
	 *
	 * Either way `beforeLabel` and `afterLabel` still name the two sides to a
	 * screen reader — hiding the captions never removes that.
	 */
	showLabels?: boolean;
	/** Accessible name for the slider. A sensible one is built if omitted. */
	ariaLabel?: string;
	/** Corner radius of the frame. */
	radius?: string;
	/** Thickness of the divider line. */
	lineWidth?: string;
	/** Diameter of the drag handle. Kept at or above 44px for touch. */
	handleSize?: string;
	/** Colour of the divider, handle and captions. */
	accent?: string;
	/** Ink used on the handle and captions. */
	ink?: string;
	class?: string;
	id?: string;
}

const {
	beforeImage,
	beforeAlt = "",
	afterImage,
	afterAlt = "",
	beforeLabel = "Before",
	afterLabel = "After",
	initialPosition = 50,
	aspectRatio = "4 / 3",
	maxWidth = "none",
	objectPosition = "center",
	showLabels = true,
	ariaLabel,
	radius = "10px",
	lineWidth = "2px",
	handleSize = "44px",
	accent = "#ffffff",
	ink = "#14161a",
	class: className,
	id,
} = Astro.props;

/* A divider outside the track would desync the visual from the input. */
const start = Math.min(100, Math.max(0, Math.round(initialPosition)));

const uid = id ?? `ic-${Math.random().toString(36).slice(2, 9)}`;
const label =
	ariaLabel ?? `Comparison slider between ${beforeLabel} and ${afterLabel}`;

/*
 * Said on every change. Both sides are named because "62%" alone does not say
 * what is at 62% — and which image is growing is the whole point.
 */
const valueText = (n: number) => `${n}% ${beforeLabel}, ${100 - n}% ${afterLabel}`;
---

<div
	class:list={["compare", className]}
	id={id}
	data-image-compare
	data-before-label={beforeLabel}
	data-after-label={afterLabel}
	style={`--ic-position:${start}%;--ic-ratio:${aspectRatio};--ic-max:${maxWidth};--ic-object:${objectPosition};--ic-radius:${radius};--ic-line:${lineWidth};--ic-handle:${handleSize};--ic-accent:${accent};--ic-ink:${ink};`}
>
	{/* The after image is the base layer; the before layer is clipped over it. */}
	<div class="layer layer-after">
		{
			afterImage ? (
				<img
					class="shot"
					src={afterImage}
					alt={afterAlt}
					draggable="false"
					decoding="async"
				/>
			) : (
				<div class="placeholder placeholder-after" aria-hidden="true">
					<span>{afterLabel}</span>
				</div>
			)
		}
	</div>

	<div class="layer layer-before">
		{
			beforeImage ? (
				<img
					class="shot"
					src={beforeImage}
					alt={beforeAlt}
					draggable="false"
					decoding="async"
				/>
			) : (
				<div class="placeholder placeholder-before" aria-hidden="true">
					<span>{beforeLabel}</span>
				</div>
			)
		}
	</div>

	{
		showLabels && (
			<>
				{/* Each caption is clipped with its own layer, so a caption is only
				    ever shown over the image it names. */}
				<div class="tag-clip tag-clip-before" aria-hidden="true">
					<span class="tag tag-before">{beforeLabel}</span>
				</div>
				<div class="tag-clip tag-clip-after" aria-hidden="true">
					<span class="tag tag-after">{afterLabel}</span>
				</div>
			</>
		)
	}

	<div class="divider" aria-hidden="true">
		<span class="handle">
			<svg
				viewBox="0 0 24 24"
				width="20"
				height="20"
				fill="none"
				stroke="currentColor"
				stroke-width="2"
				stroke-linecap="round"
				stroke-linejoin="round"
				aria-hidden="true"
			>
				<path d="m9 18-6-6 6-6" />
				<path d="m15 6 6 6-6 6" />
			</svg>
		</span>
	</div>

	<input
		class="range"
		type="range"
		min="0"
		max="100"
		step="1"
		value={String(start)}
		id={`${uid}-range`}
		aria-label={label}
		aria-valuetext={valueText(start)}
		data-range
	/>
</div>

<style>
	.compare {
		--ic-tag-bg: color-mix(in srgb, var(--ic-ink) 72%, transparent);

		box-sizing: border-box;
		position: relative;
		display: block;
		width: 100%;
		max-width: var(--ic-max);
		margin-inline: auto;
		aspect-ratio: var(--ic-ratio);
		overflow: hidden;
		isolation: isolate;
		border-radius: var(--ic-radius);
		background: color-mix(in srgb, var(--ic-ink) 12%, #000);
		/* A drag that starts on the image must not select the caption text. */
		user-select: none;
		-webkit-user-select: none;
		font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;
	}

	.compare :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	/* ---------- the two image layers ---------- */

	.layer {
		position: absolute;
		inset: 0;
	}

	/*
	 * The reveal. Both layers stay the full size of the frame — only the
	 * painted region of the top one changes — so the two images are always
	 * pixel-aligned no matter where the divider sits.
	 */
	.layer-before {
		clip-path: inset(0 calc(100% - var(--ic-position)) 0 0);
	}

	.shot {
		width: 100%;
		height: 100%;
		object-fit: cover;
		object-position: var(--ic-object);
		display: block;
		/* The range on top owns every pointer event in the frame. */
		pointer-events: none;
		-webkit-user-drag: none;
		user-select: none;
	}

	/* Shown when an image is not supplied: still two distinguishable halves,
	   so the divider has something to demonstrate. */
	.placeholder {
		display: grid;
		place-items: center;
		width: 100%;
		height: 100%;
		font-size: 0.8125rem;
		font-weight: 600;
		letter-spacing: 0.18em;
		text-transform: uppercase;
		color: color-mix(in srgb, var(--ic-accent) 55%, transparent);
	}

	.placeholder-after {
		background:
			radial-gradient(
				ellipse 80% 70% at 70% 30%,
				color-mix(in srgb, var(--ic-accent) 22%, transparent) 0%,
				transparent 70%
			),
			linear-gradient(135deg, #2a2f3a 0%, #171a21 100%);
	}

	.placeholder-before {
		background:
			repeating-linear-gradient(
				45deg,
				rgb(255 255 255 / 0.04) 0 10px,
				transparent 10px 20px
			),
			linear-gradient(135deg, #3a3a3a 0%, #1d1d1d 100%);
	}

	/* ---------- captions ---------- */

	.tag-clip {
		position: absolute;
		inset: 0;
		pointer-events: none;
	}

	.tag-clip-before {
		clip-path: inset(0 calc(100% - var(--ic-position)) 0 0);
	}

	/* The complement, so the after caption hides as its image is covered. */
	.tag-clip-after {
		clip-path: inset(0 0 0 var(--ic-position));
	}

	.tag {
		position: absolute;
		top: 0.75rem;
		padding: 0.3rem 0.6rem;
		border-radius: 999px;
		background: var(--ic-tag-bg);
		color: var(--ic-accent);
		font-size: 0.6875rem;
		font-weight: 600;
		letter-spacing: 0.1em;
		text-transform: uppercase;
		white-space: nowrap;
		backdrop-filter: blur(4px);
	}

	.tag-before {
		left: 0.75rem;
	}

	.tag-after {
		right: 0.75rem;
	}

	/* ---------- divider and handle ---------- */

	.divider {
		position: absolute;
		top: 0;
		bottom: 0;
		left: var(--ic-position);
		width: var(--ic-line);
		transform: translateX(-50%);
		background: var(--ic-accent);
		pointer-events: none;
	}

	.handle {
		position: absolute;
		top: 50%;
		left: 50%;
		transform: translate(-50%, -50%);
		display: flex;
		align-items: center;
		justify-content: center;
		width: var(--ic-handle);
		height: var(--ic-handle);
		border-radius: 50%;
		background: var(--ic-accent);
		color: var(--ic-ink);
		box-shadow:
			0 1px 2px rgb(0 0 0 / 0.25),
			0 8px 20px rgb(0 0 0 / 0.28);
		transition: box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	/* ---------- the control ---------- */

	/*
	 * A real slider, stretched over the frame and made invisible. Everything
	 * the component does — drag, touch, click anywhere to jump, arrows, Home,
	 * End, PageUp, PageDown — is the browser's own range behaviour.
	 */
	.range {
		position: absolute;
		inset: 0;
		z-index: 2;
		width: 100%;
		height: 100%;
		margin: 0;
		padding: 0;
		background: transparent;
		border: 0;
		opacity: 0;
		cursor: ew-resize;
		appearance: none;
		-webkit-appearance: none;
		touch-action: pan-y;
	}

	/* The thumb is widened so a touch drag has a forgiving target even though
	   the whole frame is draggable. */
	.range::-webkit-slider-thumb {
		appearance: none;
		-webkit-appearance: none;
		width: var(--ic-handle);
		height: var(--ic-handle);
		border-radius: 50%;
		background: transparent;
	}

	.range::-moz-range-thumb {
		width: var(--ic-handle);
		height: var(--ic-handle);
		border: 0;
		border-radius: 50%;
		background: transparent;
	}

	/* The input itself is invisible, so its focus ring is drawn on the handle
	   the reader can actually see. */
	.range:focus-visible {
		outline: none;
	}

	.compare:has(.range:focus-visible) .handle {
		box-shadow:
			0 1px 2px rgb(0 0 0 / 0.25),
			0 8px 20px rgb(0 0 0 / 0.28),
			0 0 0 4px color-mix(in srgb, var(--ic-accent) 45%, transparent);
	}

	/* Fallback for browsers without :has() — a ring on the input's own box. */
	@supports not selector(:has(*)) {
		.range:focus-visible {
			outline: 3px solid var(--ic-accent);
			outline-offset: -3px;
		}
	}

	@media (hover: hover) and (pointer: fine) {
		.compare:hover .handle {
			box-shadow:
				0 1px 2px rgb(0 0 0 / 0.25),
				0 10px 24px rgb(0 0 0 / 0.34);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.handle {
			transition: none;
		}
	}
</style>

<script>
	/*
	 * One listener per instance. The range already does the interaction; this
	 * only mirrors its value into the custom property the clip-paths read, and
	 * keeps the spoken value in step with the picture.
	 */
	const setup = (root: HTMLElement) => {
		const range = root.querySelector<HTMLInputElement>("[data-range]");
		if (!range) return;

		/* Mirrors the prop defaults, so a missing attribute cannot make the
		   spoken value disagree with the rendered one. */
		const beforeLabel = root.dataset.beforeLabel ?? "Before";
		const afterLabel = root.dataset.afterLabel ?? "After";

		const sync = () => {
			const value = Number(range.value);
			root.style.setProperty("--ic-position", `${value}%`);
			range.setAttribute(
				"aria-valuetext",
				`${value}% ${beforeLabel}, ${100 - value}% ${afterLabel}`,
			);
		};

		/* `input` covers pointer, touch and keyboard in one event. */
		range.addEventListener("input", sync);
		sync();
	};

	document.querySelectorAll<HTMLElement>("[data-image-compare]").forEach(setup);
</script>