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…

"use client"

import * as React from "react"
import { ChevronsLeftRight } from "lucide-react"
import { cn } from "@/lib/utils"

const clamp = (value: number) => Math.min(100, Math.max(0, value))

export interface ComparisonSliderProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Base layer — fully visible to the left of the divider. */
  before: React.ReactNode
  /** Top layer — clip-path revealed to the right of the divider. */
  after: React.ReactNode
  /** Corner chip over the before side. Pass "" to hide. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/comparison-slider.json

Prompt

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 capturesetPointerCapture on 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 with aria-valuenow 0–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 layersbefore/after are ReactNodes, not image URLs: filtered photos, code panels, maps or whole layouts all work, as long as both fill the frame.

On This Page