DiffATM
A side-by-side comparison of two items - drag the divider to reveal more of one or the other. Two layers share one cell; the divider is a native <input type="range">, so it drags from anywhere on the figure, steps with the arrow keys and announces its value to screen readers.
On this page (12)
§Default
Item 1 (left of the divider) over item 2 - the same photo in color and in black and white. Drag anywhere on the picture, or focus the knob and use the arrow keys, Home and End.
§Text
Any content works - here the same word, filled and outlined. Without media the figure takes the content's size.
§Old vs new design
Compare components, not only pictures: a card before and after a redesign - any markup fits in an item.
§Variants
data-variant='line' drops the knob (it appears on keyboard focus); 'primary' colors the divider and the knob ring.
§Vertical
data-orientation='vertical': item 1 on top, item 2 below, the divider drags up and down (arrow keys too).
§Follow the pointer
data-follow='hover': with a mouse the divider follows the pointer, no press needed. Touch still drags; the keyboard still steps.
§Start position and ratio
The range's value sets where the divider starts (here 25%); data-ratio 1/1 makes a square - color on the left, black and white on the right.
§States
Named states via the shared State API, bound on each .diff figure:
default- the authored position, orconfig.position(0-100)before- the divider at 100%: only item 1after- the divider at 0%: only item 2
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/diff-{state}.png.
Machine contract - verified against diff.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
position | number | — | 50 | The divider position in percent - setState('default', { position }); observed from the range's value. |
before | boolean | true, false | false | Divider at 100% - only item 1 (setState('before')). |
after | boolean | true, false | false | Divider at 0% - only item 2 (setState('after')). |
§API
Generated from diff.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type DiffState = 'default' | 'before' | 'after' - setState(name, config) takes the config of the state it names.
| State | Description | ||||||
|---|---|---|---|---|---|---|---|
default | The divider at the authored position, or at the given one.
| ||||||
before | The divider at 100% - only the first item shows.
| ||||||
after | The divider at 0% - only the second item shows.
|
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends DiffState>(name: S, config?: DiffStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: DiffState; config: DiffStateConfigs[DiffState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: DiffState; config: DiffStateConfigs[DiffState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: DiffState; config: DiffStateConfigs[DiffState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.diffApi.setState<S extends DiffState>(el: HTMLElement, name: S, config?: DiffStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.diffApi.getState(el: HTMLElement): { name: DiffState; config: DiffStateConfigs[DiffState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.diffApi.render(state: { name: DiffState; config: DiffStateConfigs[DiffState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.diffApi.store(el: HTMLElement): Store<{ name: DiffState; config: DiffStateConfigs[DiffState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.diffApi.commit<S extends DiffState>(el: HTMLElement, name: S, config?: DiffStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.diffStates: DiffState[] | The declared states, 'default' first: default, before, after. |
§CSS view file
/* -- Diff component ----------------------------------------------- */@layer components { /* Two layers in one grid cell - .diff-item-1 (before) over .diff-item-2 (after) - and a native <input type="range" class="diff-range"> spread over the whole figure as the divider: drag anywhere, arrow keys / Home / End, and a screen reader announces the value. diff.js mirrors the value into --diff-pos; item 1 is clipped at that line. Without JS the figure still shows a 50 / 50 split. The range is widened by one knob on both sides, so the knob's CENTER sits exactly on --diff-pos. */ .diff { --diff-pos: 50%; --_knob: 2.25rem; position: relative; display: grid; margin: 0; overflow: hidden; border-radius: var(--radius-lg); isolation: isolate; user-select: none; touch-action: pan-y; background-color: var(--muted); cursor: ew-resize; /* the divider line */ &::after { content: ''; position: absolute; z-index: 2; top: 0; bottom: 0; left: var(--diff-pos); width: 2px; translate: -50% 0; background-color: var(--background); box-shadow: 0 0 0 1px color-mix(in oklch, var(--foreground) 12%, transparent); pointer-events: none; } } .diff-item-1, .diff-item-2 { grid-area: 1 / 1; position: relative; min-width: 0; min-height: 0; overflow: hidden; & > :is(img, video, picture, canvas, svg) { display: block; width: 100%; height: 100%; object-fit: cover; } } .diff-item-1 { z-index: 1; clip-path: inset(0 calc(100% - var(--diff-pos)) 0 0); } .diff-item-2 { z-index: 0; } /* a corner caption on either side (Before / After) */ .diff-label { position: absolute; top: 0.75rem; left: 0.75rem; padding: 0.125rem 0.5rem; border-radius: var(--radius-sm); background-color: color-mix(in oklch, var(--background) 82%, transparent); color: var(--foreground); font-size: 0.75rem; font-weight: 500; pointer-events: none; } .diff-item-2 .diff-label { left: auto; right: 0.75rem; } /* -- The range: a knob-high strip across the middle (diff.js makes the whole figure a drag surface and keeps keyboard / AT on the range) -- */ .diff-range { position: absolute; z-index: 3; top: 50%; left: calc(var(--_knob) / -2); width: calc(100% + var(--_knob)); height: var(--_knob); translate: 0 -50%; margin: 0; padding: 0; background: transparent; appearance: none; cursor: ew-resize; outline: none; &::-webkit-slider-runnable-track { height: 100%; background: transparent; } &::-moz-range-track { height: 100%; background: transparent; } &::-webkit-slider-thumb { appearance: none; width: var(--_knob); height: var(--_knob); border: 1px solid var(--border); border-radius: 999px; background: var(--background) center / 1.125rem no-repeat url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23808080' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 6-6 6 6 6M15 6l6 6-6 6'/%3E%3C/svg%3E"); box-shadow: var(--shadow-md); transition: scale 120ms ease; } &::-moz-range-thumb { width: var(--_knob); height: var(--_knob); border: 1px solid var(--border); border-radius: 999px; background: var(--background) center / 1.125rem no-repeat url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23808080' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 6-6 6 6 6M15 6l6 6-6 6'/%3E%3C/svg%3E"); box-shadow: var(--shadow-md); } &:active::-webkit-slider-thumb { scale: 1.08; } &:focus-visible::-webkit-slider-thumb { outline: 2px solid var(--ring); outline-offset: 2px; } &:focus-visible::-moz-range-thumb { outline: 2px solid var(--ring); outline-offset: 2px; } } /* RTL: the range runs right → left, so item 1 is revealed from the right */ .diff:dir(rtl):not([data-orientation="vertical"]) { &::after { left: calc(100% - var(--diff-pos)); } & .diff-item-1 { clip-path: inset(0 0 0 calc(100% - var(--diff-pos))); } & .diff-item-1 .diff-label { left: auto; right: 0.75rem; } & .diff-item-2 .diff-label { right: auto; left: 0.75rem; } } /* -- Aspect ratios (or set aspect-ratio / a height yourself) -------- */ .diff[data-ratio="16/9"] { aspect-ratio: 16 / 9; } .diff[data-ratio="4/3"] { aspect-ratio: 4 / 3; } .diff[data-ratio="1/1"] { aspect-ratio: 1; } .diff[data-ratio="3/4"] { aspect-ratio: 3 / 4; } /* -- Knob variants ---------------------------------------------------- */ /* line: no knob - just the divider (the whole figure is still the hit area) */ .diff[data-variant="line"] .diff-range { &::-webkit-slider-thumb { opacity: 0; } &::-moz-range-thumb { opacity: 0; } &:focus-visible::-webkit-slider-thumb { opacity: 1; } } .diff[data-variant="line"]::after { width: 3px; } /* primary: a --primary divider + knob ring */ .diff[data-variant="primary"] { &::after { background-color: var(--primary); box-shadow: none; } & .diff-range::-webkit-slider-thumb { border: 2px solid var(--primary); } & .diff-range::-moz-range-thumb { border: 2px solid var(--primary); } } /* -- Vertical: before on top, after below; the range runs top → bottom */ .diff[data-orientation="vertical"] { touch-action: pan-x; cursor: ns-resize; &::after { top: var(--diff-pos); bottom: auto; left: 0; right: 0; width: auto; height: 2px; translate: 0 -50%; } & .diff-item-1 { clip-path: inset(0 0 calc(100% - var(--diff-pos)) 0); } & .diff-item-2 .diff-label { top: auto; bottom: 0.75rem; left: 0.75rem; right: auto; } & .diff-range { writing-mode: vertical-lr; top: calc(var(--_knob) / -2); left: 50%; width: var(--_knob); height: calc(100% + var(--_knob)); translate: -50% 0; cursor: ns-resize; &::-webkit-slider-runnable-track { width: 100%; height: auto; } &::-webkit-slider-thumb { rotate: 90deg; } &::-moz-range-thumb { rotate: 90deg; } } } .diff[data-orientation="vertical"][data-variant="line"]::after { height: 3px; width: auto; } /* -- Accessibility ------------------------------------------------------ */ @media (prefers-reduced-motion: reduce) { .diff-range::-webkit-slider-thumb { transition: none; } } @media (forced-colors: active) { .diff::after { background-color: CanvasText; } .diff-range::-webkit-slider-thumb { border-color: CanvasText; } }}§JS view file
/* -- Diff component ------------------------------------------------ */// Before/after comparison. The divider IS a native <input type="range"// class="diff-range"> (keyboard, touch, screen-reader value for free); this// module only mirrors its value into --diff-pos, makes the whole figure a// drag surface (pointer capture), optionally follows the pointer on hover// (data-follow="hover"), and exposes the named State API (AGENTS.md// "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();/** default = the authored (or configured) position; before / after reveal * one side completely (item 1 at 100% / item 2 at 100%). */const diffStates = ['default', 'before', 'after'];// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each// described, and every config field typed, described and named in the code./** setState() configs per state (getState() reports the divider's position). */export interface DiffStateConfigs { /** The divider at the authored position, or at the given one. */ default: { /** the divider's position, % from the start (0-100); getState() reports it */ position?: number; }; /** The divider at 100% - only the first item shows. */ before: { /** reported by getState(): 100 */ position?: number; }; /** The divider at 0% - only the second item shows. */ after: { /** reported by getState(): 0 */ position?: number; };}const rangeOf = (el) => dfDollar(el).find<HTMLInputElement>(':scope > .diff-range').get(0);/** Paint: the range's value (0..100) → --diff-pos on the figure. */function paint(el) { const range = rangeOf(el); if (!range) return; const min = parseFloat(range.min || '0'); const max = parseFloat(range.max || '100'); const pct = max === min ? 50 : ((parseFloat(range.value) - min) / (max - min)) * 100; el.style.setProperty('--diff-pos', `${pct}%`); // the observable state name follows the divider (drag / keys included) el.dataset.stateName = pct >= 100 ? 'before' : pct <= 0 ? 'after' : 'default';}/** Set the position in percent (clamped) and notify like a user edit. */function setPosition(el, pct) { const range = rangeOf(el); if (!range) return; const min = parseFloat(range.min || '0'); const max = parseFloat(range.max || '100'); const value = min + (Math.min(100, Math.max(0, pct)) / 100) * (max - min); range.value = String(value); paint(el); range.dispatchEvent(new Event('input', { bubbles: true }));}/** Pointer → percent along the figure's axis. */function pointerPct(el, e) { const r = el.getBoundingClientRect(); if (el.dataset.orientation === 'vertical') return ((e.clientY - r.top) / r.height) * 100; const x = ((e.clientX - r.left) / r.width) * 100; // RTL: the range runs right → left, so does the reveal return getComputedStyle(el).direction === 'rtl' ? 100 - x : x;}/** * The markup of a state, for render(): the attributes a state writes, applied * to a detached copy of the authored markup ('default' IS the authored * markup). The live element gets the same markup from triggerStateChange - * the e2e render round trip proves they agree. */function applyMarkup(el, stateName) { // the divider is the range's value (a property, not markup) and the // --diff-pos inline style the runtime paints: render() follows that paint const pct = stateName === 'before' ? 100 : stateName === 'after' ? 0 : null; if (pct !== null) dfDollar(el).css('--diff-pos', pct + '%');}function triggerStateChange(el, stateName, config) { switch (stateName) { case 'default': setPosition(el, config?.position ?? el._defaultPosition ?? 50); break; case 'before': setPosition(el, 100); break; case 'after': setPosition(el, 0); break; }}/** Registry-level API; pass the .diff figure explicitly. Unknown names throw. */export const diffApi = componentState({ component: 'diff', states: diffStates, apply: (el, state) => triggerStateChange(el, state.name, state.config), read: (el, state) => { // reflect reality: dragging moves the divider without setState() const pct = parseFloat(el.style.getPropertyValue('--diff-pos')) || 0; const name = pct >= 100 ? 'before' : pct <= 0 ? 'after' : 'default'; return { name, config: { ...state.config, position: Math.round(pct * 100) / 100 } }; }, markup: (el, state) => applyMarkup(el, state.name),});df$.diffApi = diffApi;df$.diffStates = diffStates;function init() { dfDollar('.diff:not([data-init])').toArray().forEach((el) => { el.dataset.init = ''; const range = rangeOf(el); if (!range) return; paint(el); el._defaultPosition = parseFloat(el.style.getPropertyValue('--diff-pos')) || 50; // el.store + el.api (AGENTS.md "State through stores") bindComponent(el, diffApi); range.addEventListener('input', () => paint(el)); // the range's fine step (0.1) keeps dragging smooth; the keyboard moves // in whole percent (Shift / Page Up/Down: 10) range.addEventListener('keydown', (e) => { const big = e.shiftKey ? 10 : 1; const deltas = { ArrowRight: big, ArrowUp: big, ArrowLeft: -big, ArrowDown: -big, PageUp: 10, PageDown: -10 }; let d = deltas[e.key]; if (d === undefined) return; e.preventDefault(); const pct = parseFloat(el.style.getPropertyValue('--diff-pos')) || 0; // vertical: the range runs top → bottom, so Down moves the divider down if (el.dataset.orientation === 'vertical' && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) d = -d; // RTL: ArrowLeft moves the divider left = toward the range's max if (el.dataset.orientation !== 'vertical' && getComputedStyle(el).direction === 'rtl' && e.key.startsWith('Arrow')) d = -d; setPosition(el, pct + d); }); // drag anywhere on the figure (the range itself handles its own knob) el.addEventListener('pointerdown', (e) => { if (e.button !== 0 || e.target === range) return; // no compat mousedown: it would move focus off the range again e.preventDefault(); el.setPointerCapture(e.pointerId); el.dataset.dragging = ''; setPosition(el, pointerPct(el, e)); range.focus({ preventScroll: true }); }); el.addEventListener('pointermove', (e) => { if (el.hasPointerCapture(e.pointerId) || (el.dataset.follow === 'hover' && e.pointerType === 'mouse')) { setPosition(el, pointerPct(el, e)); } }); const end = (e) => { if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId); delete el.dataset.dragging; }; el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub