Two pictures of the same thing, and a handle that draws the line between them.
On this page
Compare lays one picture over another of the same thing and lets a reader move the line between them: before and after an edit, light and dark, a draft and its revision. The handle is a native <input type="range">, so it has the keys, the value and the name of a slider. Its thumb is a pearl grip on a fine line, and the picture on top is clipped at that line.
Compare
<figureclass="compare max-w(520)mx-auto"><imgclass="compare-before"src="/demo/moonstone.svg"alt="The landscape before grading: grey and flat"style="aspect-ratio: 16 / 10; filter: grayscale(1) contrast(.75)"><imgclass="compare-after"src="/demo/moonstone.svg"alt="The landscape after grading"><inputtype="range"class="compare-range"min="0"max="100"value="50"aria-label="Show more of the graded picture"></figure><scripttype="module">import{ attach }from'@master/ui/behaviors'attach()</script>
Drag the grip, or focus it and use the arrow keys.
Where the browser has scroll-driven animations, the grip is a view timeline inside its input and the frame reads it: --compare-position follows the thumb with no script, and the clip and the line follow that property.
Elsewhere the comparebehavior does the same job: it copies the input's value, as a percentage of its range, into --compare-position on the root, when it attaches and each time the input moves. Attach it in every browser: where the stylesheet already drives the property, the script changes nothing that is drawn.
It takes the root, the element with the compare class, has no options and dispatches no event of its own: the input sends input and change as any range input does, and its value is where the line stands. Without the stylesheet's timeline and without the behavior, the pictures stay parted where --compare-position puts them, at the middle unless you set it.
The input is one grip wider than the frame, so the middle of the grip travels from one edge to the other and the line can reach both.
compare-before lies underneath and shows from the start to the line; compare-after lies on top and shows from the line to the end. Both fill the frame and crop to it, so give one of them the shape with a width and a height or an aspect-ratio, and use two pictures of the same framing.
The frame has a surface's corner and the inward edge every figure gets. Set --compare-radius to nest it in a card.
In a card
<divclass="card w(280)"><figureclass="compare card-media"style="--compare-radius: calc(var(--radius-surface) - 4px)"><imgclass="compare-before"src="/demo/portrait-1.svg"alt="The portrait as uploaded"style="aspect-ratio: 4 / 3; filter: saturate(.2) brightness(.92)"><imgclass="compare-after"src="/demo/portrait-1.svg"alt="The portrait with the Warm preset"><inputtype="range"class="compare-range"min="0"max="100"value="40"aria-label="Show more of the Warm preset"></figure><divclass="card-header"><h3class="card-title">Warm</h3><pclass="card-description">Drag to compare with the original.</p></div></div><scripttype="module">import{ attach }from'@master/ui/behaviors'attach()</script>
The compare component attaches the behavior and passes the position in and out. The two pictures stay the page's own markup. Copy it into the project with npx master-ui add compare; React, Svelte and Vue says how the components are built and used.
ui/compare.tsx
'use client'import{ useState,type ComponentProps }from'react'import{ compare }from'@master/ui/behaviors/compare.js'import{ useBehavior }from'./use-behavior'exportinterfaceComparePropsextendsOmit<ComponentProps<'figure'>,'defaultValue'>{/** How much of the second picture shows, from 0 to 100. */value?:numberdefaultValue?:numberonValueChange?:(value:number)=>void/** The name of the handle: what moving it shows. */label:string}/** Two pictures and the line between them. The children are the two images, `compare-before` and `compare-after`. */exportfunctionCompare({ value, defaultValue =50, onValueChange, label, className, children,...root }:CompareProps){const ref =useBehavior(compare,undefined,{ value, defaultValue, onValueChange })const[first]=useState(value ?? defaultValue)return(<figureclassName={className ?`compare ${className}`:'compare'}ref={ref}{...root}>{children}<inputtype="range"className="compare-range"min={0}max={100}defaultValue={first}aria-label={label}/></figure>)}
ui/Compare.svelte
<scriptlang="ts"module>importtype{ HTMLAttributes }from'svelte/elements'exportinterfaceComparePropsextendsHTMLAttributes<HTMLElement>{/** How much of the second picture shows, from 0 to 100. Bind it, or leave it out: it starts at 50. */value?:numberonValueChange?:(value:number)=>void/** The name of the handle: what moving it shows. */label:string}</script><scriptlang="ts">import{ untrack }from'svelte'import{ compare }from'@master/ui/behaviors/compare.js'import{ behavior }from'./behavior.svelte.js'let{ value =$bindable(), onValueChange, label,class: className, children,...root }:CompareProps=$props()const first =untrack(()=> value)??50const attach =behavior(compare,undefined,{value:()=> value,change:(next)=>{ value = next;onValueChange?.(next)},})</script><!--@component Two pictures and the line between them. The children are the two images, `compare-before` and `compare-after`.--><figureclass={['compare', className]}{@attach attach}{...root}>{@renderchildren?.()}<inputtype="range"class="compare-range"min="0"max="100"value={first}aria-label={label}/></figure>
ui/Compare.vue
<scriptsetuplang="ts">import{ useTemplateRef }from'vue'import{ compare }from'@master/ui/behaviors/compare.js'import{ useBehavior }from'./use-behavior'defineOptions({inheritAttrs:false})defineProps<{/** The name of the handle: what moving it shows. */label:string}>()/** How much of the second picture shows, from 0 to 100: `v-model`. */const model =defineModel<number>({default:50})/* Two pictures and the line between them. The default slot is the two images, `compare-before` and `compare-after`. */const first = model.valueconst root =useTemplateRef<HTMLElement>('root')useBehavior(root, compare,undefined, model)</script><template><figureclass="compare"ref="root"v-bind="$attrs"><slot /><inputtype="range"class="compare-range"min="0"max="100":value.attr="first":aria-label="label"></figure></template>
Where the line stands, from the start, as a percentage. It is registered with an initial value of 50%; the stylesheet or the script sets it from the handle
--compare-radius
var(--radius-surface)
Corner of the frame; lower it to nest the frame in a card
/* * Compare: two pictures of the same thing, one over the other, and a handle that draws the line between them. * The handle is a native range input: it has the keys, the value and the name of a slider. Its thumb is a * pearl grip on a fine line; the picture on top is clipped at the line. * Where scroll-driven animations exist the thumb's own place in the input sets --compare-position, so the * picture follows the handle with no script. Elsewhere a script copies the input's value into the same * property, and without either the pictures stay parted where --compare-position puts them. * The input is one grip wider than the frame, so the middle of the grip runs from one edge to the other. */@property--compare-position{syntax:'<percentage>';inherits: true;initial-value:50%;}@layer components {.compare{--compare-radius:var(--radius-surface);--compare-grip:2rem;position:relative;display:grid;min-width:0;margin:0;border-radius:calc(var(--compare-radius)*var(--corner-scale));corner-shape:var(--corner-shape);overflow:clip;background:var(--color-surface-chip);user-select:none;-webkit-user-select:none;isolation:isolate;/* The line between the two pictures: pearl, with a faint dark edge so it holds on a pale picture. */&::before{content:'';position:absolute;inset-block:0;inset-inline-start:var(--compare-position);z-index:1;width:1.5px;translate:-50%0;background:var(--color-on-image);box-shadow:000.5pxvar(--color-on-image-shade),006pxvar(--color-on-image-shade);pointer-events:none;}&:dir(rtl)::before{translate:50%0;}/* The inward edge every picture gets, drawn over both of them. */&::after{content:'';position:absolute;inset:0;z-index:2;border-radius:inherit;corner-shape:inherit;box-shadow:inset0001pxvar(--color-line-image);pointer-events:none;}@media(forced-colors:active){outline:1pxsolid CanvasText;outline-offset:-1px;&::before{background: CanvasText;}}}/* The two pictures share one cell and one size. */.compare-before,.compare-after{display:block;grid-area:1/1;width:100%;height:100%;max-width:none;margin:0;object-fit:cover;pointer-events:none;}/* The one on top shows from the line to the end. */.compare-after{clip-path:inset(000var(--compare-position));&:dir(rtl){clip-path:inset(0var(--compare-position)00);}}/* The handle: a range input across the middle of the frame, with no track and a grip for a thumb. */.compare-range{position:absolute;inset-block-start:50%;inset-inline:calc(var(--compare-grip)/-2);z-index:3;width:calc(100%+var(--compare-grip));height:var(--compare-grip);margin:0;padding:0;border:0;translate:0-50%;background:transparent;appearance:none;cursor:ew-resize;touch-action:pan-y;-webkit-tap-highlight-color:transparent;&:focus-visible{outline:none;}&::-webkit-slider-runnable-track{height:var(--compare-grip);background:transparent;}/* The grip: the pearl thumb of a slider, larger, with a pair of chevrons to say which way it goes. */&::-webkit-slider-thumb{width:var(--compare-grip);height:var(--compare-grip);border:0;border-radius:999px;background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none' stroke='%2334353a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m13 12-4 4 4 4M19 12l4 4-4 4'/%3E%3C/svg%3E")center/containno-repeat,linear-gradient(180deg,var(--color-thumb-high),var(--color-thumb-low));box-shadow:000.5pxvar(--color-on-image-shade),01px2px-.5pxvar(--color-on-image-shade),03px6px-2pxvar(--color-on-image-shade),08px16px-8pxvar(--color-on-image-shade);appearance:none;transition: scale var(--duration-quick)var(--easing-glide);}&::-moz-range-track{height:var(--compare-grip);background:transparent;}&::-moz-range-thumb{width:var(--compare-grip);height:var(--compare-grip);border:0;border-radius:999px;background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none' stroke='%2334353a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m13 12-4 4 4 4M19 12l4 4-4 4'/%3E%3C/svg%3E")center/containno-repeat,linear-gradient(180deg,var(--color-thumb-high),var(--color-thumb-low));box-shadow:000.5pxvar(--color-on-image-shade),01px2px-.5pxvar(--color-on-image-shade),03px6px-2pxvar(--color-on-image-shade),08px16px-8pxvar(--color-on-image-shade);}&:active::-webkit-slider-thumb{scale:1.06;}&:focus-visible::-webkit-slider-thumb{outline:2pxsolidvar(--color-on-image);outline-offset:2px;box-shadow:0002pxvar(--color-focus),000.5pxvar(--color-on-image-shade),03px6px-2pxvar(--color-on-image-shade);}&:focus-visible::-moz-range-thumb{outline:2pxsolidvar(--color-on-image);outline-offset:2px;}@media(pointer:coarse){--compare-grip:2.75rem;}@media(prefers-reduced-motion:reduce){&:active::-webkit-slider-thumb{scale:none;}}@media(forced-colors:active){&::-webkit-slider-thumb{forced-color-adjust:none;background: ButtonText;box-shadow:none;}}}/* The handle moves the picture: the grip is a view timeline in its input and the frame reads it. */@supports(view-timeline: --compare-grip inline)and(timeline-scope: --compare-grip){.compare{timeline-scope: --compare-grip;animation-name: compare-position;animation-timing-function:linear;animation-fill-mode:both;animation-timeline: --compare-grip;animation-range:contain;&:dir(rtl){animation-direction:reverse;}}.compare-range{overflow:hidden;&::-webkit-slider-thumb{view-timeline: --compare-grip inline;}}}}@keyframes compare-position {from{--compare-position:100%;}to{--compare-position:0%;}}