{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slice-gallery",
  "type": "registry:ui",
  "title": "Slice Gallery",
  "description": "Tall photo slices that share one width. The one you open widens into a full card while the others squeeze to make room; its title swings upright and the details slide up from under it.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/slice-gallery.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n  type MotionStyle,\n  type MotionValue,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * SLICE GALLERY STORYBOARD\n *\n *  rest     tall slices share the width. One is open; the rest\n *           are thin windows onto their photos, shaded, with\n *           their titles running up the left edge\n *  hover    a slice brightens as soon as the pointer is on it,\n *           and opens when the pointer slows or settles, so a\n *           quick sweep across the row doesn't set it rippling\n *  open     it widens on a spring while the others squeeze to\n *           make room. Photos never stretch: each is cut to the\n *           open size, so the window just widens, and the photo\n *           eases back from a slight zoom. The title swings\n *           upright on its first letter, then rises as the\n *           details slide up from under it, and the strip on\n *           top warms to the slice's accent\n *  close    the details go first, then the title swings back\n *           down as the slice narrows\n *  touch    tap a slice, or drag a finger across the row to\n *           scrub through them\n *  keys     ←/→ (↑/↓ when stacked), Home and End\n *  play     with autoPlay, the open slice's strip fills like a\n *           story timer, then the next one opens. It pauses\n *           while the pointer or keyboard focus is inside, or\n *           the gallery is off screen\n *  narrow   under 36rem the slices stack and open downward\n *  reduced  no springs, zoom or autoplay: slices switch at once\n * ───────────────────────────────────────────────────────── */\n\nconst SLICE = {\n  open: { type: \"spring\", visualDuration: 0.5, bounce: 0 },\n  dwell: 90, // ms the pointer rests on a slice before it opens\n  slow: 0.35, // px/ms; a pointer moving slower than this opens a slice at once\n  zoom: 0.08, // closed photos sit this much closer in\n  shade: 0.4, // darkness over closed slices\n  pad: 20, // px around a slice's text when the slices sit side by side (p-5)\n  headroom: 104, // px a sideways title leaves at the top for the strip and label\n  stack: 36, // rem; narrower galleries stack their slices (Tailwind's @xl)\n  scrub: 10, // px a finger travels before a tap counts as a scrub\n} as const;\n\nexport type SliceGalleryOrientation = \"auto\" | \"horizontal\" | \"vertical\";\n\n// Literal class names so Tailwind generates them. \"auto\" switches at the gallery's own @xl width.\nconst DIRECTION: Record<SliceGalleryOrientation, string> = {\n  auto: \"flex-col @xl:flex-row\",\n  horizontal: \"flex-row\",\n  vertical: \"flex-col\",\n};\n// Side by side, a closed title lies along the slice's left edge, turning on its first letter.\nconst TURN: Record<SliceGalleryOrientation, string> = {\n  auto: \"@xl:rotate-[calc(var(--slice-turn)*-90deg)]\",\n  horizontal: \"rotate-[calc(var(--slice-turn)*-90deg)]\",\n  vertical: \"\",\n};\nconst PAD: Record<SliceGalleryOrientation, string> = {\n  auto: \"p-4 @xl:p-5\",\n  horizontal: \"p-5\",\n  vertical: \"p-4\",\n};\n// The label sits in the top corner side by side, and at the end of the title row when stacked.\nconst LABEL_TOP: Record<SliceGalleryOrientation, string> = {\n  auto: \"hidden @xl:block\",\n  horizontal: \"block\",\n  vertical: \"hidden\",\n};\nconst LABEL_ROW: Record<SliceGalleryOrientation, string> = {\n  auto: \"@xl:hidden\",\n  horizontal: \"hidden\",\n  vertical: \"\",\n};\n\ntype Layout = {\n  horizontal: boolean;\n  /** Length of the open slice along the row, in px. Null until measured. */\n  open: number | null;\n  /** Depth of the row across the slices (its height side by side), in px. */\n  cross: number | null;\n};\n\ntype Gallery = {\n  active: number;\n  grow: number;\n  orientation: SliceGalleryOrientation;\n  layout: Layout;\n  reduced: boolean;\n  playing: boolean;\n  progress: MotionValue<number>;\n  select: (index: number) => void;\n  onKeyDown: (event: React.KeyboardEvent, from: number) => void;\n};\n\nconst GalleryContext = React.createContext<Gallery | null>(null);\nconst IndexContext = React.createContext(-1);\n\nfunction useGallery() {\n  const gallery = React.useContext(GalleryContext);\n  if (!gallery) throw new Error(\"<SliceGalleryItem> must be rendered inside <SliceGallery>.\");\n  return gallery;\n}\n\n/* ── SliceGallery ───────────────────────────────────────── */\n\nexport interface SliceGalleryProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** The open slice. Leave unset to let the gallery manage it. */\n  index?: number;\n  /** The slice that starts open. */\n  defaultIndex?: number;\n  /** Called when a slice opens: hover, tap, scrub, keys or autoplay. */\n  onIndexChange?: (index: number) => void;\n  /** How many times longer the open slice is than a closed one. */\n  grow?: number;\n  /** Space between slices, in px. */\n  gap?: number;\n  /** \"hover\" opens slices under the pointer (and under a dragging finger); \"click\" waits for a click or tap. */\n  openOn?: \"hover\" | \"click\";\n  /** Opens the next slice every this many ms, with the strip as its timer. Off for reduced motion. */\n  autoPlay?: number;\n  /** \"auto\" sits the slices side by side, and stacks them when the gallery is narrower than 36rem. */\n  orientation?: SliceGalleryOrientation;\n}\n\n/**\n * Tall slices that share a fixed width. The open one widens into a full card\n * and the rest squeeze thinner to make room.\n */\nexport function SliceGallery({\n  index,\n  defaultIndex = 0,\n  onIndexChange,\n  grow = 5,\n  gap = 8,\n  openOn = \"hover\",\n  autoPlay,\n  orientation = \"auto\",\n  className,\n  style,\n  children,\n  ...props\n}: SliceGalleryProps) {\n  const items = React.Children.toArray(children).filter(React.isValidElement);\n  const count = items.length;\n  const [own, setOwn] = React.useState(defaultIndex);\n  const active = count ? Math.min(Math.max(index ?? own, 0), count - 1) : -1;\n  const reduced = useReducedMotion() ?? false;\n  const playing = !!autoPlay && autoPlay > 0 && !reduced && count > 1;\n\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const activeRef = React.useRef(active);\n  const latest = React.useRef({ index, onIndexChange, count });\n  React.useLayoutEffect(() => {\n    activeRef.current = active;\n    latest.current = { index, onIndexChange, count };\n  });\n\n  const select = React.useCallback((next: number) => {\n    const { index, onIndexChange, count } = latest.current;\n    if (next === activeRef.current || next < 0 || next >= count) return;\n    activeRef.current = next;\n    if (index === undefined) setOwn(next);\n    onIndexChange?.(next);\n  }, []);\n\n  /* Measure: which way the slices run, and how long the open one is. */\n  const [layout, setLayout] = React.useState<Layout>(() => ({\n    horizontal: orientation !== \"vertical\",\n    open: null,\n    cross: null,\n  }));\n  React.useLayoutEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n    const observer = new ResizeObserver(() => {\n      const width = list.offsetWidth;\n      const height = list.offsetHeight;\n      const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;\n      const horizontal =\n        orientation === \"horizontal\" || (orientation === \"auto\" && width >= SLICE.stack * rem);\n      const others = Math.max(0, count - 1);\n      const main = horizontal ? width : height;\n      const open = Math.round(((main - gap * others) * grow) / (grow + others));\n      const cross = horizontal ? height : width;\n      setLayout((prev) =>\n        prev.horizontal === horizontal && prev.open === open && prev.cross === cross\n          ? prev\n          : { horizontal, open, cross },\n      );\n    });\n    observer.observe(list);\n    return () => observer.disconnect();\n  }, [orientation, gap, grow, count]);\n\n  /* Autoplay: the progress runs while nothing holds it, then opens the next slice. */\n  const progress = useMotionValue(0);\n  const [hovered, setHovered] = React.useState(false);\n  const [focused, setFocused] = React.useState(false);\n  const [seen, setSeen] = React.useState(true);\n  const paused = hovered || focused || !seen;\n\n  React.useLayoutEffect(() => {\n    progress.jump(0);\n  }, [active, progress]);\n\n  React.useEffect(() => {\n    if (!playing || paused) return;\n    const controls = animate(progress, 1, {\n      duration: ((autoPlay ?? 0) / 1000) * (1 - progress.get()),\n      ease: \"linear\",\n      onComplete: () => select((activeRef.current + 1) % latest.current.count),\n    });\n    return () => controls.stop();\n  }, [playing, paused, active, autoPlay, progress, select]);\n\n  React.useEffect(() => {\n    const list = listRef.current;\n    if (!playing || !list) return;\n    let inView = true;\n    const update = () => setSeen(inView && document.visibilityState === \"visible\");\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        inView = entry.isIntersecting;\n        update();\n      },\n      { threshold: 0.2 },\n    );\n    observer.observe(list);\n    document.addEventListener(\"visibilitychange\", update);\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", update);\n    };\n  }, [playing]);\n\n  /* Pointer: hover intent for mice, scrubbing for fingers. */\n  const pointer = React.useRef({\n    x: NaN,\n    y: NaN,\n    t: 0,\n    speed: Infinity,\n    timer: 0,\n    scrub: false,\n    startX: 0,\n    swallowClick: false,\n  });\n  React.useEffect(() => () => window.clearTimeout(pointer.current.timer), []);\n\n  const sliceAt = (x: number, y: number) => {\n    const hit = document.elementFromPoint(x, y)?.closest<HTMLElement>(\"[data-slice-index]\");\n    return hit && listRef.current?.contains(hit) ? Number(hit.dataset.sliceIndex) : -1;\n  };\n\n  const onPointerMove = (event: React.PointerEvent) => {\n    if (openOn !== \"hover\") return;\n    const p = pointer.current;\n    if (event.pointerType !== \"mouse\") {\n      if (p.scrub) select(sliceAt(event.clientX, event.clientY));\n      return;\n    }\n    const dx = event.clientX - p.x;\n    const dy = event.clientY - p.y;\n    if (dx === 0 && dy === 0) return; // the slices moved under a still pointer\n    const dt = event.timeStamp - p.t;\n    const speed = Number.isNaN(dx) ? Infinity : dt > 0 ? Math.hypot(dx, dy) / dt : p.speed;\n    p.speed = Number.isFinite(p.speed) && Number.isFinite(speed) ? (p.speed + speed) / 2 : speed;\n    p.x = event.clientX;\n    p.y = event.clientY;\n    p.t = event.timeStamp;\n\n    window.clearTimeout(p.timer);\n    const next = sliceAt(p.x, p.y);\n    if (next < 0 || next === activeRef.current) return;\n    if (p.speed < SLICE.slow) select(next);\n    else p.timer = window.setTimeout(() => select(next), SLICE.dwell);\n  };\n\n  const onPointerDown = (event: React.PointerEvent) => {\n    const p = pointer.current;\n    p.swallowClick = false;\n    if (event.pointerType === \"mouse\" || !layout.horizontal || openOn !== \"hover\") return;\n    p.scrub = true;\n    p.startX = event.clientX;\n  };\n\n  const endScrub = (event: React.PointerEvent) => {\n    const p = pointer.current;\n    if (!p.scrub) return;\n    p.scrub = false;\n    // A finger that slid across the row shouldn't also tap the slice it started on.\n    p.swallowClick = Math.abs(event.clientX - p.startX) > SLICE.scrub;\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent, from: number) => {\n    const back = layout.horizontal ? \"ArrowLeft\" : \"ArrowUp\";\n    const forward = layout.horizontal ? \"ArrowRight\" : \"ArrowDown\";\n    let to = -1;\n    if (event.key === forward) to = (from + 1) % count;\n    else if (event.key === back) to = (from - 1 + count) % count;\n    else if (event.key === \"Home\") to = 0;\n    else if (event.key === \"End\") to = count - 1;\n    if (to < 0) return;\n    event.preventDefault();\n    select(to);\n    listRef.current\n      ?.querySelector<HTMLElement>(`[data-slice-index=\"${to}\"] [data-slice-trigger]`)\n      ?.focus();\n  };\n\n  const titleMax =\n    layout.horizontal && layout.open && layout.cross\n      ? `${Math.max(0, Math.min(layout.open - 2 * SLICE.pad, layout.cross - SLICE.headroom))}px`\n      : undefined;\n\n  return (\n    <GalleryContext.Provider\n      value={{ active, grow, orientation, layout, reduced, playing, progress, select, onKeyDown }}\n    >\n      <div\n        {...props}\n        className={cn(\"@container relative h-[30rem] w-full\", className)}\n        style={{ ...style, \"--slice-title-max\": titleMax } as React.CSSProperties}\n      >\n        <div\n          ref={listRef}\n          className={cn(\"flex size-full\", DIRECTION[orientation])}\n          style={{ gap, touchAction: layout.horizontal && openOn === \"hover\" ? \"pan-y\" : undefined }}\n          onPointerMove={onPointerMove}\n          onPointerDown={onPointerDown}\n          onPointerUp={endScrub}\n          onPointerCancel={endScrub}\n          onPointerEnter={(event) => event.pointerType === \"mouse\" && setHovered(true)}\n          onPointerLeave={() => {\n            const p = pointer.current;\n            window.clearTimeout(p.timer);\n            p.x = NaN;\n            p.speed = Infinity;\n            setHovered(false);\n          }}\n          onClickCapture={(event) => {\n            if (!pointer.current.swallowClick) return;\n            pointer.current.swallowClick = false;\n            event.preventDefault();\n            event.stopPropagation();\n          }}\n          onFocus={(event) => {\n            if (event.target.matches(\":focus-visible\")) setFocused(true);\n          }}\n          onBlur={(event) => {\n            if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);\n          }}\n        >\n          {items.map((item, i) => (\n            <IndexContext.Provider key={item.key ?? i} value={i}>\n              {item}\n            </IndexContext.Provider>\n          ))}\n        </div>\n      </div>\n    </GalleryContext.Provider>\n  );\n}\n\n/* ── SliceGalleryItem ───────────────────────────────────── */\n\nexport interface SliceGalleryItemProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  /** One short line. Runs up the edge of a closed slice and turns upright when it opens. */\n  title: React.ReactNode;\n  /** Photo URL. It's cut to the open slice's size, so closed slices show a strip of its middle. */\n  image: string;\n  /** Alt text for the photo. Leave empty when the title says it all. */\n  alt?: string;\n  /** Small text in the top corner, like a year or a number. Moves to the end of the title row when stacked. */\n  label?: React.ReactNode;\n  /** Color the strip on top warms to when the slice opens. */\n  accent?: string;\n  /** Classes for the photo. The slice is a `group/slice` with `data-state=\"open\"` or `\"closed\"`. */\n  imageClassName?: string;\n  /** Details that slide up under the title when the slice opens. */\n  children?: React.ReactNode;\n}\n\n/** One slice. Render inside `SliceGallery`. */\nexport function SliceGalleryItem({\n  title,\n  image,\n  alt = \"\",\n  label,\n  accent = \"#ffffff\",\n  imageClassName,\n  className,\n  children,\n  ...props\n}: SliceGalleryItemProps) {\n  const { active, grow, orientation, layout, reduced, playing, progress, select, onKeyDown } =\n    useGallery();\n  const index = React.useContext(IndexContext);\n  const isOpen = active === index;\n  const id = React.useId();\n  const titleId = `${id}-title`;\n  const detailsId = `${id}-details`;\n\n  const open = useMotionValue(isOpen ? 1 : 0);\n  React.useEffect(() => {\n    const target = isOpen ? 1 : 0;\n    if (reduced) {\n      open.jump(target);\n      return;\n    }\n    const controls = animate(open, target, SLICE.open);\n    return () => controls.stop();\n  }, [isOpen, reduced, open]);\n\n  const flexGrow = useTransform(open, [0, 1], [1, grow]);\n  const turn = useTransform(open, [0.15, 0.85], [1, 0]);\n  const reveal = useTransform(open, [0.4, 1], [0, 1]);\n  const shade = useTransform(open, [0, 1], [SLICE.shade, 0]);\n  const zoom = useTransform(open, [0, 1], [1 + SLICE.zoom, 1]);\n  const glow = useTransform(open, [0.2, 0.8], [0, 1]);\n\n  // The strip fills with the autoplay timer while open, and holds its fill as it fades out.\n  const fill = useMotionValue(playing && isOpen ? 0 : 1);\n  useMotionValueEvent(progress, \"change\", (value) => {\n    if (playing && isOpen) fill.set(value);\n  });\n  React.useEffect(() => {\n    if (!playing) fill.set(1);\n    else if (isOpen) fill.set(progress.get());\n  }, [playing, isOpen, fill, progress]);\n\n  const length = layout.open ?? undefined;\n  const horizontal = layout.horizontal;\n\n  return (\n    <motion.div\n      data-slice-index={index}\n      className=\"min-h-0 min-w-0 basis-0\"\n      style={{ flexGrow }}\n    >\n      <div\n        {...props}\n        data-state={isOpen ? \"open\" : \"closed\"}\n        className={cn(\n          \"group/slice relative isolate size-full overflow-hidden rounded-2xl bg-zinc-800 outline-zinc-900 select-none dark:outline-zinc-100\",\n          \"has-[[data-slice-trigger]:focus-visible]:outline-2 has-[[data-slice-trigger]:focus-visible]:outline-offset-2\",\n          className,\n        )}\n      >\n        <motion.img\n          src={image}\n          alt={alt}\n          draggable={false}\n          decoding=\"async\"\n          className={cn(\n            \"pointer-events-none absolute top-1/2 left-1/2 max-w-none -translate-x-1/2 -translate-y-1/2 object-cover\",\n            imageClassName,\n          )}\n          style={{\n            width: horizontal && length ? length : \"100%\",\n            height: !horizontal && length ? length : \"100%\",\n            scale: reduced ? 1 : zoom,\n          }}\n        />\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgb(0_0_0/0.78),rgb(0_0_0/0.42)_28%,rgb(0_0_0/0)_62%,rgb(0_0_0/0.22))]\"\n        />\n        <motion.div aria-hidden className=\"pointer-events-none absolute inset-0\" style={{ opacity: shade }}>\n          <div className=\"size-full bg-black transition-opacity duration-300 group-hover/slice:opacity-50\" />\n        </motion.div>\n\n        <button\n          type=\"button\"\n          data-slice-trigger\n          tabIndex={isOpen ? 0 : -1}\n          aria-expanded={isOpen}\n          aria-controls={detailsId}\n          aria-labelledby={titleId}\n          onClick={() => select(index)}\n          onKeyDown={(event) => onKeyDown(event, index)}\n          className={cn(\"absolute inset-0 z-10 outline-none\", isOpen ? \"cursor-default\" : \"cursor-pointer\")}\n        />\n\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-x-3 top-3 z-20 h-[3px] overflow-hidden rounded-full bg-white/25\"\n        >\n          <motion.div\n            className=\"h-full origin-left rounded-full\"\n            style={{ scaleX: fill, opacity: glow, backgroundColor: accent }}\n          />\n        </div>\n\n        {label != null && (\n          <span\n            className={cn(\n              \"pointer-events-none absolute top-7 left-5 z-20 max-w-[calc(100%-2.5rem)] truncate font-mono text-xs tracking-wide text-white/75\",\n              LABEL_TOP[orientation],\n            )}\n          >\n            {label}\n          </span>\n        )}\n\n        <div\n          className={cn(\n            \"pointer-events-none absolute inset-x-0 bottom-0 z-20 text-white [text-shadow:0_1px_14px_rgb(0_0_0/0.3)]\",\n            PAD[orientation],\n          )}\n        >\n          <motion.div\n            className=\"translate-y-[calc((100%-2rem)*(1-var(--slice-reveal)))]\"\n            style={\n              {\n                \"--slice-reveal\": reveal,\n                width: horizontal && length ? length - 2 * SLICE.pad : undefined,\n              } as MotionStyle\n            }\n          >\n            <div className=\"flex h-8 items-center gap-3\">\n              <motion.span\n                id={titleId}\n                className={cn(\n                  \"max-w-[var(--slice-title-max,100%)] min-w-0 origin-[1rem_1rem] truncate text-xl leading-8 font-medium tracking-tight\",\n                  TURN[orientation],\n                )}\n                style={{ \"--slice-turn\": turn } as MotionStyle}\n              >\n                {title}\n              </motion.span>\n              {label != null && (\n                <span\n                  className={cn(\n                    \"ml-auto shrink-0 font-mono text-xs tracking-wide text-white/75\",\n                    LABEL_ROW[orientation],\n                  )}\n                >\n                  {label}\n                </span>\n              )}\n            </div>\n            <motion.div\n              id={detailsId}\n              inert={!isOpen}\n              className=\"pointer-events-auto pt-2 select-text\"\n              style={{ opacity: reveal }}\n            >\n              {children}\n            </motion.div>\n          </motion.div>\n        </div>\n      </div>\n    </motion.div>\n  );\n}\n"
    }
  ]
}