Media
Comparison Slider
A draggable before/after slider — two aligned layers, one clip-path divider, works with any ReactNode.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/comparison-slider.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ComparisonSlider" component
(lucide-react for the handle icon).
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props: before: ReactNode (base layer) and after: ReactNode (top layer) —
usually two <img>, but the component never assumes images; beforeLabel /
afterLabel (default "Before" / "After", pass "" to hide the chip);
initial?: number (divider position 0–100, default 50, clamped);
aspect?: string (CSS aspect-ratio, default "16/9"); className merged via cn().
- Consumer pointer handlers (onPointerDown/Move/Up/Cancel) still fire after
the internal drag logic.
Behavior
- One position state (0–100). The after layer sits on top of the before layer,
clipped with clip-path: inset(0 0 0 position%) — before shows left of the
divider, after shows right of it; both layers always render full-size so
pixels stay aligned.
- Dragging uses Pointer Events on the container: pointerdown sets a dragging
ref, calls setPointerCapture, and immediately converts clientX into a
percentage of the container rect (clamp 0–100) — pressing anywhere jumps the
divider to that spot and the drag continues from there. pointermove updates
while dragging (capture keeps tracking outside the frame); pointerup /
pointercancel end the drag. Capture auto-releases — no window listeners to
clean up.
- Keyboard: the handle is role="slider" with tabIndex 0, aria-valuemin 0 /
aria-valuemax 100 / aria-valuenow (rounded), aria-orientation="horizontal".
ArrowLeft/ArrowRight step ±5, Home → 0, End → 100 (all preventDefault).
A pointer press focuses the handle so arrow keys work right after a drag.
- Guard against a zero-width rect before dividing. Position updates apply
immediately with no transition — the divider must stay under the pointer.
There is no animation, so prefers-reduced-motion needs no special handling.
Rendering & styling
- Container: relative w-full overflow-hidden rounded-xl border,
cursor-ew-resize, select-none (no text/image selection mid-drag),
touch-pan-y (vertical page scroll stays native; horizontal moves drive the
slider), inline style aspectRatio from the aspect prop.
- Layers: two absolute inset-0 wrappers; the after wrapper carries the
clip-path inline style.
- Labels: small chips at top-left / top-right — rounded-full bg-background/70
backdrop-blur px-2.5 py-1 text-xs, pointer-events-none.
- Divider: absolute inset-y-0 w-0.5 bg-background bar at left: position% with
-translate-x-1/2; handle: centered size-8 rounded-full bg-background border
shadow-md holding a ChevronsLeftRight icon (text-muted-foreground). Focus
ring on the knob via group-focus-visible:ring-2 ring-ring (outline-none on
the slider element). Decorative divider/knob nodes are aria-hidden.
- Semantic tokens only: bg-background, border, text-foreground,
text-muted-foreground, ring-ring — no hardcoded colors.
Customization levers
- Vertical comparison: swap the axis — clip with inset(position% 0 0 0), read
clientY against rect.height, position the divider with top + inset-x-0,
aria-orientation="vertical", ArrowUp/Down keys, touch-pan-x,
cursor-ns-resize. The state machine is unchanged.
- Controlled value: add value?: number + onValueChange?: (v: number) => void;
route every internal setPosition through onValueChange and prefer the value
prop when provided — initial then only seeds the uncontrolled path.
- Labels: move the chips to the bottom corners, restyle (bg-primary
text-primary-foreground for a bolder chip), or pass "" to drop them.
- Keyboard step: the ±5 constant — 1 for fine control, 10 for coarse;
optionally add Shift+Arrow as a larger step.
- Handle style: swap ChevronsLeftRight for another icon, shrink the knob to
size-6 for dense UIs, or hide the knob and keep only the w-0.5 divider —
keep the role="slider" element and its focus ring either way.
- Frame: aspect drives the ratio; drop rounded-xl border for a flush
full-bleed hero usage.Concepts
- Clip-path reveal — both layers render full-size in the same frame; the top (after) layer is clipped with
inset(0 0 0 x%), so pixels stay perfectly aligned and only the boundary moves — no resizing, no reflow. - Pointer capture —
setPointerCaptureon pointerdown routes every later move back to the container, so the drag keeps tracking even when the pointer leaves the frame, and release is automatic on pointerup — no window listeners to leak. - Jump-to-click — a press anywhere converts clientX into a 0–100 percentage of the container rect and moves the divider there instantly; the same press starts the drag, so click and drag are one gesture.
- Slider semantics — the handle is a
role="slider"element witharia-valuenow0–100; arrow keys step ±5, Home/End snap to the edges, and a pointer press focuses it so keyboard refinement follows a drag naturally. - Direct manipulation — position updates apply synchronously with no transition, keeping the divider glued to the finger; with no animation there is nothing to gate behind reduced motion.
- Content-agnostic layers —
before/afterare ReactNodes, not image URLs: filtered photos, code panels, maps or whole layouts all work, as long as both fill the frame.