Component library
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. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 3 files appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Image comparison slider from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/image-comparison-slider/

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

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.

Install kit

The 3 files this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Image comparison slider"
Library page: https://astro.baysixmedia.com/components/image-comparison-slider/
Files: 3 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/components/ImageComparisonSlider.astro =====
---
/**
 * 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>

===== END src/components/ImageComparisonSlider.astro =====

===== FILE (supporting): src/components/ImageComparisonSlider.md =====
# Image comparison slider

Two versions of one scene stacked in a single frame, with a divider you drag,
tap, click or arrow across to reveal each. Built on a real `<input type="range">`,
so pointer, touch, keyboard and the accessibility tree all come from the
browser rather than from hand-written event handling.

Catalog page: `/components/image-comparison-slider`

## Copying it into another Astro project

Copy **`ImageComparisonSlider.astro`**. That is the whole component.

| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--ic-*` property on the root |
| Webfonts | none — system stack |
| Bundled images | **none in the component** — the catalog demo ships two; see below |
| Network requests | none, beyond the images you pass |
| Host requirement | Astro compiles `.astro` files |

### Exact steps

1. Copy `ImageComparisonSlider.astro` into your components folder, e.g.
   `src/components/ImageComparisonSlider.astro`.
2. Put your two images somewhere the host serves them, then render it:

```astro
---
import ImageComparisonSlider from "../components/ImageComparisonSlider.astro";
---

<ImageComparisonSlider
  beforeImage="/shots/kitchen-before.webp"
  beforeAlt="The kitchen before the refit, with the original cabinets"
  afterImage="/shots/kitchen-after.webp"
  afterAlt="The same kitchen after the refit, with new cabinets"
  beforeLabel="Before"
  afterLabel="After"
  initialPosition={50}
  aspectRatio="4 / 3"
  maxWidth="48rem"
/>
```

3. There is no step three. With no `beforeImage` or `afterImage` at all, the
   component draws a two-tone placeholder instead of breaking, so you can drop
   it in and wire the images up afterwards.

**The two images must be the same size and framing.** The component keeps them
pixel-aligned (see *How the reveal works*), but it cannot fix a pair that were
cropped differently to begin with — that misalignment is in the files.

## The demo's image dependency

The catalog demo passes two images, so copying it as-you-see-it needs these as
well as the component:

```
public/image-comparison/ridge-ungraded.webp   (14 KB)
public/image-comparison/ridge-graded.webp     (19 KB)
```

Nothing in the `.astro` file imports them — they are passed by URL — so they
will not follow the component on their own. The catalog's source panel lists
them under **assets** for that reason, and the copy button includes them.

### Where the demo images came from

Both are **original**, generated for this catalog — no stock photography, no
third party, nothing to credit.

A stylised sunrise over layered ridges was built as a single HTML page of CSS
gradients and `clip-path` silhouettes, then rendered headless at 1200×900 and
screenshotted twice:

- **`ridge-graded.webp`** — the scene as authored: warm, saturated, contrasty.
- **`ridge-ungraded.webp`** — the *identical* scene with one CSS filter applied
  to the whole stage, `saturate(0.14) contrast(0.80) brightness(1.16)`, standing
  in for a flat, ungraded original.

Because only a filter differs between the two renders, **every edge in the two
files lands on exactly the same pixel**. That is the ideal case for a
comparison slider, and it is why the demo shows no drift as the divider moves.

To regenerate or restyle them, rebuild that scene page, render it at your
chosen size, and screenshot once with the filter and once without.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `beforeImage` | `string` | — | Image revealed on the left. Omit for the placeholder. |
| `beforeAlt` | `string` | `""` | Alternative text for the before image. |
| `afterImage` | `string` | — | Image revealed on the right. Omit for the placeholder. |
| `afterAlt` | `string` | `""` | Alternative text for the after image. |
| `beforeLabel` | `string` | `"Before"` | Names the left side. Spoken always; drawn only with `showLabels`. |
| `afterLabel` | `string` | `"After"` | Names the right side. Spoken always; drawn only with `showLabels`. |
| `initialPosition` | `number` | `50` | Where the divider starts, 0–100. Clamped. |
| `aspectRatio` | `string` | `"4 / 3"` | Frame ratio, e.g. `"16 / 9"`. |
| `maxWidth` | `string` | `"none"` | Caps the frame width and centres it. |
| `objectPosition` | `string` | `"center"` | Applied to **both** images, so they crop identically. |
| `showLabels` | `boolean` | `true` | Draw the two captions. `false` for a bare frame. |
| `ariaLabel` | `string` | built from the labels | Accessible name for the slider. |
| `radius` | `string` | `"10px"` | Corner radius of the frame. |
| `lineWidth` | `string` | `"2px"` | Thickness of the divider. |
| `handleSize` | `string` | `"44px"` | Diameter of the handle. Keep it ≥ 44px for touch. |
| `accent` | `string` | `"#ffffff"` | Divider, handle and caption colour. |
| `ink` | `string` | `"#14161a"` | Ink on the handle and captions. |
| `class` | `string` | — | Added to the root element. |
| `id` | `string` | auto | Root element id. Also seeds the range's id. |

## Captions

Captions are **visible by default**, reading **Before** and **After**: a small
pill in the top-left over the before image and one in the top-right over the
after image.

That means the component needs no label props at all to be self-explanatory —
the catalog demo passes none, so what you see there is the out-of-the-box
state. Pass `beforeLabel` and `afterLabel` when your two images deserve more
specific names:

```astro
<ImageComparisonSlider
  beforeImage="/shots/roof-2019.webp"
  afterImage="/shots/roof-2024.webp"
  beforeLabel="2019"
  afterLabel="2024"
/>
```

Pass `showLabels={false}` for a bare frame — just the two images and the
divider — when the surrounding page already says what is being compared:

```astro
<ImageComparisonSlider
  beforeImage="/shots/roof-2019.webp"
  afterImage="/shots/roof-2024.webp"
  beforeLabel="2019"
  afterLabel="2024"
  showLabels={false}
/>
```

**`beforeLabel` and `afterLabel` do work whether or not they are drawn.** They
name the two sides in the slider's accessible name and in `aria-valuetext`, so
a screen reader hears *"38% 2019, 62% 2024"* even with `showLabels={false}`.
So set them to something meaningful even when you hide the captions: with the
defaults the reader hears *"38% Before, 62% After"*, which is accurate but says
nothing about what the two images are.

Each caption is clipped with its own layer — the before caption with the same
inset as the before image, the after caption with the complement — so a caption
is only ever drawn over the image it names, and both disappear cleanly as the
divider reaches either end.

## How the reveal works

Both layers are the **full size of the frame** at all times. Only the painted
region of the top one changes:

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

Nothing is resized, so the two images cannot drift apart — measured identical
at 0%, 50% and 100% and at every position in between. The obvious alternative,
shrinking a wrapper's `width`, squashes the image inside it unless you also pin
that image to the container's width; clipping avoids the problem rather than
compensating for it.

The captions, when `showLabels` is on, are clipped the same way.

## Behaviour and accessibility

The control is a real `<input type="range">` stretched over the whole frame at
`opacity: 0`. That single decision supplies:

| Input | Behaviour | Verified |
| :--- | :--- | :--- |
| Mouse drag | Divider follows, both directions | ✓ |
| Touch / pen drag | Same, via pointer events | ✓ |
| **Click or tap anywhere** | Divider jumps to that point | ✓ |
| `←` / `→` | ±1% | ✓ |
| `Home` / `End` | 0% / 100% | ✓ |
| `PageUp` / `PageDown` | ±10% | ✓ |
| Tab | Enters the tab order as a slider | ✓ |

- **It is announced as a slider**, because it is one — no invented
  `role="slider"`, and `min`, `max` and `value` are real attributes.
- **`aria-valuetext` names both sides** and updates on every change, e.g.
  *"62% Before, 38% After"*. A bare percentage would not say what is at 62%,
  and which image is growing is the whole point.
- **The focus ring is drawn on the handle**, since the input itself is
  invisible. A `@supports not selector(:has(*))` block falls back to an outline
  on the input's own box.
- **The images cannot be dragged or selected**: `draggable="false"`,
  `pointer-events: none`, `-webkit-user-drag: none`, and `user-select: none` on
  the frame. Every pointer event in the frame belongs to the range.
- **`touch-action: pan-y`** on the range, so a vertical swipe still scrolls the
  page while a horizontal one moves the divider.
- **Reduced motion:** `prefers-reduced-motion: reduce` removes the handle's
  transition. Dragging is not animation — it tracks the pointer — so it is
  unaffected, and the component stays fully usable.
- **Several per page:** each instance gets its own listener and its own range
  id, seeded from `id` or a generated uid. Verified with three on one page: two
  images and a placeholder, different positions, ratios and labels, each moving
  independently.

### Without JavaScript

The range still works and is still keyboard-operable, but the divider will not
follow it — the script's only job is copying the range's value into
`--ic-position`. The frame renders at `initialPosition`, which is a valid
static comparison rather than a broken one.

## Customisation

Everything visual is a custom property on the root, so a wrapper can restyle it
without touching the file:

```css
.my-compare {
  --ic-accent: #ffd166;   /* divider, handle, captions */
  --ic-ink: #1a1400;      /* ink on the handle */
  --ic-line: 3px;
  --ic-handle: 52px;
  --ic-radius: 0;
}
```

Pass `class="my-compare"` to attach it. `accent`, `ink`, `lineWidth`,
`handleSize` and `radius` are also plain props if you would rather set them
inline.

## Notes on the reference

The interaction pattern — an overlaid range input, a thin divider with a round
chevron handle, a 4:3 frame with a 10px radius — was studied from a live
component gallery. **None of that component's source, wording, images or
branding came with it.** The markup, styles, script, captions, placeholder and
both demo images are written or generated here.

Two deliberate differences:

- **The reference reveals by shrinking a wrapper's width** and pins the inner
  image with `min-width: 100cqw` to stop it squashing. This clips instead,
  which needs no container query and cannot desync.
- **The reference's own demo is visibly misaligned** — its two images are
  different crops returned by an image service, so the ridgeline jumps across
  the divider. The demo here uses one scene rendered twice, so it does not.

===== END src/components/ImageComparisonSlider.md =====

===== FILE (required): public/image-comparison/ridge-ungraded.webp =====
[binary asset — download https://astro.baysixmedia.com/image-comparison/ridge-ungraded.webp and save it to public/image-comparison/ridge-ungraded.webp]

===== FILE (required): public/image-comparison/ridge-graded.webp =====
[binary asset — download https://astro.baysixmedia.com/image-comparison/ridge-graded.webp and save it to public/image-comparison/ridge-graded.webp]

--- VERIFICATION ---
This component is not enrolled in the library's verification system, so there is no
recorded evidence about it either way. Treat it as unverified and test it yourself.

--- TEST IN THIS PROJECT AFTER INSTALLING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

A fragment for 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 branding and content open. It does not carry the code.

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.

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>