Fylgja Compare Slider

A compare slider stacks two images of the same scene and lets the reader drag a divider across them, to show a before and after or two versions side by side. The control is a native <input type="range"> laid over the images, so it is focusable, works with the arrow keys and touch, and announces its value without any ARIA. The range position is read in CSS, so the split follows the thumb without JavaScript.

Preview

Drag the handle, or focus it and use the arrow keys.

Deer in a forest, in color The same photo in black and white
<div class="compare-slider stack rounded" data-compare-slider>
	<img
		class="w-full h-full object-cover"
		src="/images/placeholders/deer-wide.webp"
		alt="Deer in a forest, in color"
		width="960"
		height="640"
	/>
	<img
		class="w-full h-full object-cover"
		src="/images/placeholders/deer-wide.webp"
		alt="The same photo in black and white"
		width="960"
		height="640"
		style="filter: grayscale(1)"
	/>
	<label for="compare-deer" class="sr-only">
		Amount of the color photo shown
	</label>
	<input
		class="h-full"
		type="range"
		id="compare-deer"
		min="0"
		max="100"
		value="50"
	/>
	<span
		class="compare-slider-value align-self rounded"
		style="--align-self: start; --m: 0.75rem; --p: 0.25em 0.6em"
		aria-hidden="true"
	></span>
</div>

<style>
	@property --compare-percent {
		syntax: "<integer>";
		inherits: false;
		initial-value: 0;
	}

	.compare-slider {
		--range-value: 50;
		--compare-line: 2px;
		--compare-handle: 2.5em;
		--compare-color: var(--root-bg);
		--_split: calc(var(--range-value) * 1%);
		/* Firefox measures the thumb against the nearest scroll container, so this must be one */
		overflow: hidden;
		timeline-scope: --range-thumb;
		animation: range-observer linear reverse both;
		animation-timeline: --range-thumb;

		img:nth-of-type(2) {
			clip-path: inset(0 0 0 var(--_split));
		}

		&:dir(rtl) img:nth-of-type(2) {
			clip-path: inset(0 var(--_split) 0 0);
		}

		&::before,
		&::after {
			grid-area: 1 / 1;
			z-index: 1;
			justify-self: start;
			pointer-events: none;
		}

		&::before {
			content: "";
			inline-size: var(--compare-line);
			margin-inline-start: calc(var(--_split) - var(--compare-line) / 2);
			background: var(--compare-color);
		}

		&::after {
			content: "\2194" / "";
			display: grid;
			place-content: center;
			align-self: center;
			inline-size: var(--compare-handle);
			aspect-ratio: 1;
			margin-inline-start: calc(var(--_split) - var(--compare-handle) / 2);
			border-radius: 50%;
			background: var(--compare-color);
			color: var(--root-fg);
			box-shadow: 0 1px 4px rgb(0 0 0 / 0.3);
		}

		&:has(input:focus-visible)::after {
			outline: 2px solid var(--root-fg);
			outline-offset: 2px;
		}

		input {
			--track-color: transparent;
			--track-fill: transparent;
			--thumb-size-x: var(--compare-line);
			--thumb-size-y: var(--compare-handle);
			--thumb-color: transparent;
			--thumb-stroke-size: 0;
			z-index: 2;
			outline: none;
			cursor: ew-resize;
			timeline-scope: none;
		}

		/* form-extend only names the thumb timeline on the WebKit thumb */
		input::-moz-range-thumb {
			view-timeline: --range-thumb inline;
		}

		.compare-slider-value {
			--compare-percent: calc(var(--range-value));
			z-index: 1;
			counter-reset: percent var(--compare-percent);
			background: var(--compare-color);
			color: var(--root-fg);
			font-variant-numeric: tabular-nums;
			pointer-events: none;
		}

		.compare-slider-value::after {
			content: counter(percent) "%";
		}
	}
</style>

Usage

  1. Wrap two images and a range input in an element with stack, so they share one grid cell. The first image is the base, the second is clipped by the slider.
  2. Keep both images the same size and aspect ratio, so the two halves line up.
  3. Give the range min="0", max="100", and a starting value, and name it with a visually hidden <label>.
  4. Add the <style> block. It moves the --range-value from form-extend up to the wrapper with timeline-scope, so the second image, the divider line, and the percentage label can all read it. The ::-moz-range-thumb rule names the same timeline in Firefox, which form-extend only sets on the WebKit thumb.

Restyle the handle with --compare-handle and the divider with --compare-line. --compare-color sets the color of both. Keep the range thumb thin: form-extend measures its position including its width, and a wide thumb makes --range-value drift from the real value towards the ends.

Anatomy

Part Responsibility
.compare-slider.stack Layers every child in one grid cell and shares --range-value.
First <img> The base image, visible on the start side of the divider.
Second <img> The compared image, clipped from the start edge up to the divider.
::before The divider line that follows the thumb.
::after The round handle, drawn at the split.
<input type=range> The control, stretched over the images with a transparent track and thumb.
.compare-slider-value The visual percentage, written with a CSS counter.

Accessibility

  • The range input is the only control, so it gets the keyboard behavior for free: the arrow keys move the divider, and Home and End jump to either side.
  • Name the range by what it changes, such as “Amount of the color photo shown”, not “Slider”.
  • Give both images an alt that says what is different, so a screen reader user knows what is being compared without seeing it.
  • The percentage label is aria-hidden, since the range already announces its value.

Browser support

The CSS only version relies on scroll-driven animations and timeline-scope. In a browser without them the split stays at the starting --range-value. Add this script once per page to move the split on input there. It only runs where timeline-scope is missing.

<script>
	if (!CSS.supports("timeline-scope: --a")) {
		for (const slider of document.querySelectorAll("[data-compare-slider]")) {
			const range = slider.querySelector("input[type=range]");
			const update = () => slider.style.setProperty("--range-value", range.value);
			range.addEventListener("input", update);
			update();
		}
	}
</script>

Related: Range, Carousel, Gallery.

FAQ

Does the compare slider need JavaScript?

Not in browsers with scroll-driven animations. form-extend tracks the thumb and exposes its position as --range-value, and the slider reads that to clip the top image. Older browsers need the small fallback script from the Browser support section.

Can I compare something other than images?

Yes. Any two layers of the same size work, such as two pictures, two videos, or two styled blocks of markup. The second layer is the one that gets clipped.

How do I start at a different split?

Change the value attribute on the range input. Also update the --range-value on the wrapper, so browsers without scroll-driven animations start at the same split.

Why is the percentage hidden from screen readers?

The range input already announces its value. Reading the visual label as well would say every number twice.