{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "before-after",
  "title": "Before and after",
  "description": "Two photos of the same place on top of each other, with a handle that wipes from before to after.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/manniche/before-after/before-after.tsx",
      "content": "// Before and after: two photos of the same place on top of each other, with a handle that wipes between them.\n// The after photo fills the frame; the before photo lies over it and is cut off at the handle, so before is on the\n// left and after on the right.\n//\n// Drag anywhere on the picture, or click to jump the handle there. The frame keeps `touch-action: pan-y`, so on a\n// phone a vertical swipe still scrolls the page and only a sideways drag moves the handle. The keyboard and screen\n// readers use a real range input (hidden, but focusable): arrows move 1 %, Page Up/Down 10 %, Home and End go to the\n// ends, and its value is read as \"40 % before\". Focus shows as a ring on the handle.\n//\n// Motion: none of its own. The handle follows the pointer or the key, and nothing animates by itself.\nimport { useId, useRef, useState, type PointerEvent } from 'react'\nimport { cn } from '@/lib/utils'\n\nexport type BeforeAfterLabels = {\n  /** The chip on the before photo. */\n  before: string\n  /** The chip on the after photo. */\n  after: string\n  /** The name of the slider, read by screen readers. */\n  slider: string\n  /** The slider's spoken value; `{n}` is the share of the before photo in per cent. */\n  value: string\n}\n\nconst LABELS: BeforeAfterLabels = {\n  before: 'Before',\n  after: 'After',\n  slider: 'Compare before and after',\n  value: '{n} % before',\n}\n\nexport type BeforeAfterProps = {\n  /** The before photo. */\n  before: { src: string; alt: string }\n  /** The after photo, taken from the same spot. */\n  after: { src: string; alt: string }\n  /** Where the handle starts, in per cent from the left. */\n  initial?: number\n  /** Width to height of the frame, as CSS `aspect-ratio`. Both photos are cropped to it. */\n  aspectRatio?: string\n  /** Called with the new position (0–100) when it changes. */\n  onChange?: (position: number) => void\n  /** Visible and spoken text. */\n  labels?: Partial<BeforeAfterLabels>\n  /** Classes for the frame. */\n  className?: string\n}\n\nconst clamp = (n: number) => Math.min(100, Math.max(0, n))\n\n/** Two photos of the same place with a handle that wipes from before to after. */\nexport function BeforeAfter({ before, after, initial = 50, aspectRatio = '4 / 3', onChange, labels = {}, className }: BeforeAfterProps) {\n  const l = { ...LABELS, ...labels }\n  const id = useId()\n  const frame = useRef<HTMLDivElement>(null)\n  const [pos, setPos] = useState(() => clamp(initial))\n\n  const set = (n: number) => {\n    const next = clamp(Math.round(n * 10) / 10)\n    setPos(next)\n    onChange?.(next)\n  }\n  const fromPointer = (e: PointerEvent) => {\n    const box = frame.current?.getBoundingClientRect()\n    if (box && box.width > 0) set(((e.clientX - box.left) / box.width) * 100)\n  }\n\n  return (\n    <div\n      ref={frame}\n      className={cn('relative isolate cursor-ew-resize touch-pan-y overflow-hidden rounded-xl border border-border bg-muted select-none', className)}\n      style={{ aspectRatio }}\n      onPointerDown={(e) => {\n        if (e.button !== 0) return\n        e.currentTarget.setPointerCapture(e.pointerId)\n        fromPointer(e)\n      }}\n      onPointerMove={(e) => {\n        if (e.currentTarget.hasPointerCapture(e.pointerId)) fromPointer(e)\n      }}\n    >\n      <img src={after.src} alt={after.alt} draggable={false} className=\"absolute inset-0 size-full object-cover\" />\n      <img\n        src={before.src}\n        alt={before.alt}\n        draggable={false}\n        className=\"absolute inset-0 size-full object-cover\"\n        style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }}\n      />\n\n      <span aria-hidden className=\"pointer-events-none absolute top-3 left-3 rounded-md bg-background/85 px-2 py-1 text-xs font-medium text-foreground\">\n        {l.before}\n      </span>\n      <span aria-hidden className=\"pointer-events-none absolute top-3 right-3 rounded-md bg-background/85 px-2 py-1 text-xs font-medium text-foreground\">\n        {l.after}\n      </span>\n\n      <input\n        id={id}\n        type=\"range\"\n        min={0}\n        max={100}\n        step={1}\n        value={Math.round(pos)}\n        aria-label={l.slider}\n        aria-valuetext={l.value.replace('{n}', String(Math.round(pos)))}\n        onChange={(e) => set(Number(e.target.value))}\n        className=\"peer sr-only\"\n      />\n      <div aria-hidden className=\"pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-background shadow-[0_0_0_1px_rgb(0_0_0/0.15)]\" style={{ left: `${pos}%` }} />\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute top-1/2 flex size-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-md peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background\"\n        style={{ left: `${pos}%` }}\n      >\n        <svg viewBox=\"0 0 24 24\" className=\"size-5\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n          <path d=\"m9 7-5 5 5 5M15 7l5 5-5 5\" />\n        </svg>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "tier": "free",
    "added": "2026-10-24",
    "usedIn": [],
    "props": [
      {
        "name": "before",
        "type": "{ src: string; alt: string }",
        "default": null,
        "required": true,
        "description": "The before photo."
      },
      {
        "name": "after",
        "type": "{ src: string; alt: string }",
        "default": null,
        "required": true,
        "description": "The after photo, taken from the same spot."
      },
      {
        "name": "initial",
        "type": "number",
        "default": "50",
        "required": false,
        "description": "Where the handle starts, in per cent from the left."
      },
      {
        "name": "aspectRatio",
        "type": "string",
        "default": "'4 / 3'",
        "required": false,
        "description": "Width to height of the frame, as CSS `aspect-ratio`. Both photos are cropped to it."
      },
      {
        "name": "onChange",
        "type": "(position: number) => void",
        "default": null,
        "required": false,
        "description": "Called with the new position (0–100) when it changes."
      },
      {
        "name": "labels",
        "type": "Partial<BeforeAfterLabels>",
        "default": "{}",
        "required": false,
        "description": "Visible and spoken text."
      },
      {
        "name": "className",
        "type": "string",
        "default": null,
        "required": false,
        "description": "Classes for the frame."
      }
    ],
    "a11y": [
      {
        "topic": "keyboard",
        "text": "The handle is a real range input, hidden but focusable. Tab focuses it, arrows move 1 %, Page Up and Page Down 10 %, Home and End go to the ends.",
        "keys": [
          "Tab",
          "ArrowLeft",
          "ArrowRight",
          "PageUp",
          "PageDown",
          "Home",
          "End"
        ]
      },
      {
        "topic": "screen-reader",
        "text": "The input is named by labels.slider and its value is read as labels.value, \"40 % before\" by default. Both photos keep their own alt text, so each is described. The chips and the handle drawing are aria-hidden."
      },
      {
        "topic": "other",
        "text": "Focus shows as a 2 px ring with offset on the round handle, through peer-focus-visible on the hidden input."
      },
      {
        "topic": "other",
        "text": "The handle is 44 px. The frame has touch-action pan-y, so a vertical swipe scrolls the page and only a sideways drag moves the handle."
      },
      {
        "topic": "motion",
        "text": "Nothing animates by itself. The handle only follows the pointer or the key, so reduced motion needs no change."
      }
    ]
  },
  "categories": [
    "widgets"
  ],
  "type": "registry:component"
}