Theme
On this page (11)
Component Skill — components/diff/component-skill.md

Native basis

<figure> with two stacked layers + <input type="range"> as the divider; clip-path: inset() does the reveal.

Web Platform APIs

<input type="range">clip-path: inset()setPointerCapture()writing-mode<figure>forced-colors

Classes

.diff.diff-item-1.diff-item-2.diff-range.diff-label

Data attributes

data-ratio (16/9, 4/3, 1/1, 3/4), data-variant (line, primary), data-orientation="vertical", data-follow="hover".

§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.

§Right to left

In an RTL context the range runs right to left - and the reveal follows it.

§States

Named states via the shared State API, bound on each .diff figure:

  • default - the authored position, or config.position (0-100)
  • before - the divider at 100%: only item 1
  • after - 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:

StateTypeValuesDefaultDescription
positionnumber—50The divider position in percent - setState('default', { position }); observed from the range's value.
beforebooleantrue, falsefalseDivider at 100% - only item 1 (setState('before')).
afterbooleantrue, falsefalseDivider at 0% - only item 2 (setState('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 } from '../../shared/state-api.js';
const df$ = defussGlobals();
/** 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'];
const rangeOf = (el) => el.querySelector(':scope > .diff-range');
/** 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;
}
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 = {
  setState(el, stateName, config = {}) {
    if (!diffStates.includes(stateName)) {
      throw new Error(`diff: unknown state "${stateName}" (supported: ${diffStates.join(', ')})`);
    }
    triggerStateChange(el, stateName, config);
    // state lives on the ELEMENT, not the module (many diffs per page)
    el.dataset.stateName = stateName;
    el._stateConfig = config;
  },
  getState(el) {
    // 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: { ...el._stateConfig, position: Math.round(pct * 100) / 100 } };
  },
};
df$.diffApi = diffApi;
df$.diffStates = diffStates;
function init() {
  document.querySelectorAll('.diff:not([data-init])').forEach((el) => {
    el.dataset.init = '';
    const range = rangeOf(el);
    if (!range) return;
    paint(el);
    el._defaultPosition = parseFloat(el.style.getPropertyValue('--diff-pos')) || 50;
    el.api = {
      setState: (stateName, config) => diffApi.setState(el, stateName, config),
      getState: () => diffApi.getState(el),
    };
    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