{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-comparison",
  "title": "Image Comparison",
  "description": "A before/after slider: two images stacked in the same box with a divider you drag across them, so the second is revealed over the first instead of sitting next to it. Reach for it wherever a page has to show the same frame twice and the point is the difference: a photo edit or retouch shown against the original, an AI upscale, denoise, colourise, restore or background-removal result next to its input, a generative fill or inpainting demo, a model-A-vs-model-B output pair, a renovation or remodel gallery, before-and-after in a portfolio or case study, a design revision against the version it replaced, satellite or map imagery of the same place at two dates, a scan or microscopy image with and without processing, graphics settings or shader quality in a game, a screenshot in light and dark theme, and image compression quality (original against the optimised file). Common asks it answers: \"before after slider\", \"before and after image component\", \"image comparison slider\", \"compare two images react\", \"split image slider\", \"image reveal slider\", \"drag to compare photos\", \"photo comparison component\", \"before/after react component\", \"img-comparison-slider alternative\", \"react-compare-image alternative\", \"react-compare-slider alternative\", \"shadcn before after\", \"shadcn image comparison\". Official shadcn/ui has nothing for this and no combination of its parts gets there: slider is a Radix range primitive that knows nothing about images, aspect-ratio only holds a box at a shape, and carousel shows pictures one after another rather than one over the other. Distinct from diff-view, which compares two pieces of text line by line — this one compares two pictures of the same thing. Pass `before` and `after` and that is the whole setup: `before` lays out in normal flow and gives the pair its height, `after` is overlaid and clipped, and `beforeLabel` / `afterLabel` pin captions to the corners that disappear when their side is closed. It is uncontrolled by default (`defaultPosition`) and controlled by passing `position` with `onPositionChange`, which fires on every drag frame and key press. The interaction is a real range input covering the whole picture rather than a mousedown/mousemove pair, which is where hand-rolled versions come apart. Dragging works from anywhere on the image, a click jumps the divider, pointer capture keeps the drag alive when the cursor leaves the box, and touch and pen work without a second code path — none of which a mouse-event implementation gets, and it cannot be operated from a keyboard at all. Here the arrow keys move the divider by one percent, Page Up and Page Down by ten, Home and End go to the ends, and each press snaps to the step's own grid so a keyboard user lands on whole numbers instead of inheriting the fraction a drag left behind. Three details it settles that are invisible until they are wrong. The thumb is one pixel wide, because a range maps the pointer onto the track minus the thumb, and a default 16px thumb leaves the drawn divider drifting up to eight pixels from the finger near the edges. `touch-action: pan-y` keeps a vertical swipe scrolling the page, so an image that spans a phone screen is not a trap you cannot scroll past. And the input is `dir=\"ltr\"` whatever the page direction, because 0 has to mean the left edge — a divider is a place on a picture, not a position in a line of text. Accessibility is the pair, not just the control: clipping is visual, so both images stay in the accessibility tree and a screen reader reads both alt texts, while the divider is a labelled slider that announces its position as a percentage. The focus ring is drawn on the visible handle through the transparent input, so it is themed rather than a browser outline over a photograph. Styled entirely with shadcn tokens (background, border, foreground, ring), so it follows light and dark mode, and it ships zero dependencies — no Radix, no icon package, one file.",
  "files": [
    {
      "path": "registry/ui/image-comparison.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface ImageComparisonProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\" | \"onChange\"> {\n  /**\n   * What the left of the divider shows — usually an `<img>`, but anything that fills a box\n   * works (a `next/image`, a `<video>`, a canvas, a styled div).\n   *\n   * This one is laid out in normal flow, so it is what gives the component its height. Pass\n   * the image whose aspect ratio the pair should be read at; the other is stretched to match.\n   */\n  before: React.ReactNode\n  /** What the right of the divider shows. Overlaid on `before` and clipped to fit its box. */\n  after: React.ReactNode\n  /** Caption pinned to the top-left corner, e.g. \"Original\". Hidden once its side is closed. */\n  beforeLabel?: React.ReactNode\n  /** Caption pinned to the top-right corner, e.g. \"Restored\". Hidden once its side is closed. */\n  afterLabel?: React.ReactNode\n  /**\n   * Where the divider starts, as a percentage of the width from the left (default 50).\n   * Ignored once `position` is passed.\n   */\n  defaultPosition?: number\n  /** Divider position for a controlled component. Values outside 0–100 are clamped. */\n  position?: number\n  /** Called with the new position on every drag frame and every key press. */\n  onPositionChange?: (position: number) => void\n  /**\n   * How far one arrow key moves the divider, in percent (default 1). Page Up and Page Down\n   * move ten of these; Home and End jump to the ends. Dragging is unaffected — a pointer is\n   * continuous and stepping it would only make it stutter.\n   */\n  keyboardStep?: number\n  /** Accessible name for the divider, e.g. \"Compare original and restored\". */\n  \"aria-label\"?: string\n  /** Classes for the divider line and its knob, e.g. \"bg-primary\". */\n  handleClassName?: string\n}\n\nconst DEFAULT_POSITION = 50\n\n/**\n * Arrow keys move by one step; Page Up/Down by ten. Up and Right increase, per WAI-ARIA.\n *\n * Typed as possibly undefined so that the lookup below has to be checked: with a plain\n * `Record<string, number>` every key on the keyboard would come back typed as a number.\n */\nconst KEY_STEPS: Record<string, number | undefined> = {\n  ArrowRight: 1,\n  ArrowUp: 1,\n  ArrowLeft: -1,\n  ArrowDown: -1,\n  PageUp: 10,\n  PageDown: -10,\n}\n\nfunction clampPosition(n: number): number {\n  // NaN fails both comparisons and would otherwise reach clip-path as \"NaN%\", which most\n  // browsers drop — leaving the after image uncut and the comparison silently broken.\n  if (!Number.isFinite(n)) return DEFAULT_POSITION\n  return n < 0 ? 0 : n > 100 ? 100 : n\n}\n\n/**\n * The next position on the step's own grid, in the direction of travel.\n *\n * Stepping by addition alone would carry the fractional part of a drag forever: let go at\n * 37.42 and the arrow key takes you to 38.42, 39.42, and Page Up to 47.42. Snapping to the\n * grid instead means a keyboard user always lands on whole numbers they can predict, while\n * a press never moves less than the step or skips a stop.\n */\nfunction stepFrom(position: number, step: number): number {\n  const size = Math.abs(step)\n  const grid = step > 0 ? Math.floor(position / size) : Math.ceil(position / size)\n  return (grid + Math.sign(step)) * size\n}\n\n/**\n * A before/after slider: two images stacked in the same box with a divider you drag across\n * them. The left of the divider shows `before`, the right shows `after`.\n *\n * ```tsx\n * <ImageComparison\n *   className=\"aspect-video\"\n *   before={<img src=\"/before.jpg\" alt=\"The kitchen before the remodel\" />}\n *   after={<img src=\"/after.jpg\" alt=\"The kitchen after the remodel\" />}\n *   beforeLabel=\"Before\"\n *   afterLabel=\"After\"\n * />\n * ```\n *\n * Both images stay in the accessibility tree — clipping is a visual effect, not a hiding\n * one — so a screen reader reads both alt texts and gets the comparison the sighted reader\n * is dragging for. Write the two alts as a pair that describes the difference, and give the\n * divider an `aria-label` naming what is being compared.\n */\nexport const ImageComparison = React.forwardRef<HTMLDivElement, ImageComparisonProps>(\n  function ImageComparison(\n    {\n      className,\n      before,\n      after,\n      beforeLabel,\n      afterLabel,\n      defaultPosition = DEFAULT_POSITION,\n      position: positionProp,\n      onPositionChange,\n      keyboardStep = 1,\n      handleClassName,\n      \"aria-label\": ariaLabel = \"Comparison slider\",\n      ...props\n    },\n    ref\n  ) {\n    const [uncontrolled, setUncontrolled] = React.useState(() =>\n      clampPosition(defaultPosition)\n    )\n    const isControlled = positionProp !== undefined\n    const position = clampPosition(isControlled ? positionProp : uncontrolled)\n\n    const commit = React.useCallback(\n      (next: number) => {\n        const value = clampPosition(next)\n        if (!isControlled) setUncontrolled(value)\n        onPositionChange?.(value)\n      },\n      [isControlled, onPositionChange]\n    )\n\n    const step = Number.isFinite(keyboardStep) && keyboardStep > 0 ? keyboardStep : 1\n\n    const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n      if (event.altKey || event.ctrlKey || event.metaKey) return\n\n      if (event.key === \"Home\" || event.key === \"End\") {\n        event.preventDefault()\n        commit(event.key === \"Home\" ? 0 : 100)\n        return\n      }\n\n      const multiple = KEY_STEPS[event.key]\n      if (multiple === undefined) return\n      event.preventDefault()\n      commit(stepFrom(position, multiple * step))\n    }\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          \"relative isolate select-none overflow-hidden rounded-md border border-border\",\n          className\n        )}\n        {...props}\n      >\n        {/*\n          In flow, so the pair is as tall as this image renders — no aspect ratio to declare\n          and no reflow when it loads. Give the wrapper a class instead of asking callers to\n          style their own <img>: the component owns the box, and an image that keeps its\n          intrinsic width would break the overlay's alignment with it.\n        */}\n        <div className=\"[&>*]:block [&>*]:h-auto [&>*]:w-full\">{before}</div>\n\n        {/*\n          clip-path rather than a width-constrained wrapper with overflow hidden: the image\n          keeps the full box as its layout size, so the visible sliver is the right-hand part\n          of the same picture rather than a copy squeezed into a narrow column. object-cover\n          absorbs a mismatch in aspect ratio, which is the honest failure — cropping the edges\n          of one photo, instead of stretching it into disagreement with the other.\n        */}\n        <div\n          className=\"absolute inset-0 [&>*]:h-full [&>*]:w-full [&>*]:object-cover\"\n          style={{ clipPath: `inset(0 0 0 ${position}%)` }}\n        >\n          {after}\n        </div>\n\n        {/*\n          A real range input covering the whole picture, rather than pointer maths on the\n          container. Dragging from anywhere, click-to-jump, pointer capture that survives the\n          cursor leaving the box, touch, arrow keys, and a value announced by screen readers\n          all come from the platform, and none of them is what a hand-rolled version gets\n          right — those usually ship a mousedown/mousemove pair, which strands the divider\n          when the pointer leaves and cannot be operated from the keyboard at all.\n\n          - step=\"any\" so a drag is continuous; keys are handled above, where a countable step\n            is what a keyboard wants.\n          - The thumb is one pixel wide, because a range maps the pointer onto the track minus\n            the thumb's width. A default thumb is about 16px, which would leave the drawn\n            divider drifting up to 8px away from the finger near the edges.\n          - touch-action: pan-y, so a vertical swipe over the picture still scrolls the page.\n            The input covers everything, so the alternative is an image you cannot scroll past\n            on a phone.\n          - dir=\"ltr\", because value 0 has to mean the left edge whatever the page direction:\n            the divider is a place on a picture, not a position in a line of text.\n          - Transparent, not hidden: opacity keeps it focusable and operable while the visible\n            handle below is drawn to the theme, and peer-focus-visible moves the focus ring\n            onto that handle.\n        */}\n        <input\n          type=\"range\"\n          min={0}\n          max={100}\n          step=\"any\"\n          value={position}\n          onChange={(event) => commit(event.currentTarget.valueAsNumber)}\n          onKeyDown={handleKeyDown}\n          dir=\"ltr\"\n          aria-label={ariaLabel}\n          aria-valuetext={`${Math.round(position)}%`}\n          className={cn(\n            \"peer absolute inset-0 z-10 m-0 h-full w-full cursor-ew-resize touch-pan-y appearance-none bg-transparent p-0 opacity-0\",\n            \"[&::-webkit-slider-thumb]:h-full [&::-webkit-slider-thumb]:w-px [&::-webkit-slider-thumb]:appearance-none\",\n            \"[&::-moz-range-thumb]:h-full [&::-moz-range-thumb]:w-px [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:border-0\"\n          )}\n        />\n\n        {/*\n          The line and the knob are siblings of the input rather than one nested pair, because\n          peer variants reach siblings only — a knob inside the line would never see focus.\n        */}\n        <div\n          aria-hidden=\"true\"\n          className={cn(\n            \"pointer-events-none absolute inset-y-0 z-20 w-0.5 -translate-x-1/2 bg-background shadow-sm peer-focus-visible:bg-ring\",\n            handleClassName\n          )}\n          style={{ left: `${position}%` }}\n        />\n        <div\n          aria-hidden=\"true\"\n          className={cn(\n            \"pointer-events-none absolute top-1/2 z-20 flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-md\",\n            \"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background\",\n            handleClassName\n          )}\n          style={{ left: `${position}%` }}\n        >\n          {/* Inline, so the component costs no icon dependency for its one glyph. */}\n          <svg\n            width=\"16\"\n            height=\"16\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            aria-hidden=\"true\"\n          >\n            <path d=\"m14 7 5 5-5 5M10 7l-5 5 5 5\" />\n          </svg>\n        </div>\n\n        {/*\n          A caption for a side with no width left is a caption sitting on the other picture,\n          so each one goes when its side closes. Hidden from assistive technology either way:\n          they name the two images, which have alt text of their own, and the divider is\n          already announced with a name and a percentage.\n        */}\n        {beforeLabel != null ? (\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"pointer-events-none absolute left-2 top-2 z-20 rounded-sm bg-background/80 px-1.5 py-0.5 text-xs font-medium text-foreground backdrop-blur-sm transition-opacity\",\n              position <= 0 && \"opacity-0\"\n            )}\n          >\n            {beforeLabel}\n          </span>\n        ) : null}\n        {afterLabel != null ? (\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"pointer-events-none absolute right-2 top-2 z-20 rounded-sm bg-background/80 px-1.5 py-0.5 text-xs font-medium text-foreground backdrop-blur-sm transition-opacity\",\n              position >= 100 && \"opacity-0\"\n            )}\n          >\n            {afterLabel}\n          </span>\n        ) : null}\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}