{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "overflow-scroller",
  "type": "registry:ui",
  "title": "Overflow Scroller",
  "description": "A horizontal row whose edge fades and arrows appear only where content hides.",
  "categories": [
    "scroll"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/overflow-scroller.tsx",
      "content": "\"use client\";\nimport { animate, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronLeft, ChevronRight } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\n\n/**\n * Whether a horizontal scroller has more content before or after what is in\n * view. Re-measures on scroll, resize and when the content changes size.\n */\nexport function useScrollEdges(ref: React.RefObject<HTMLElement | null>) {\n  const [edges, setEdges] = useState({ start: false, end: false });\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    let frame = 0;\n    const read = () => {\n      const max = el.scrollWidth - el.clientWidth;\n      const next = { start: el.scrollLeft > 1, end: el.scrollLeft < max - 1 };\n      setEdges((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));\n    };\n    const onScroll = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(read);\n    };\n    const ro = new ResizeObserver(onScroll);\n    ro.observe(el);\n    for (const child of el.children) ro.observe(child);\n    el.addEventListener(\"scroll\", onScroll, { passive: true });\n    read();\n    return () => {\n      cancelAnimationFrame(frame);\n      ro.disconnect();\n      el.removeEventListener(\"scroll\", onScroll);\n    };\n  }, [ref]);\n  return edges;\n}\n\nexport type OverflowScrollerProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  children: React.ReactNode;\n  /** Names the list for screen readers, e.g. \"Filters\". */\n  label?: string;\n  /** Width of the edge fade in px. The arrows sit inside it. */\n  fade?: number;\n  /** Arrow buttons for pointers that can't swipe. \"none\" keeps only the fades. */\n  controls?: \"arrows\" | \"none\";\n  size?: \"sm\" | \"md\";\n  /** On mount, bring the selected item (aria-pressed, aria-selected, aria-current or data-active) into view. */\n  revealActive?: boolean;\n  /** Class for the inner row that holds the items (gap, padding, alignment). */\n  trackClassName?: string;\n};\n\nconst MASK =\n  \"linear-gradient(to right, transparent calc(var(--fade-start, 0px) * 0.55), var(--fg) var(--fade-start, 0px), var(--fg) calc(100% - var(--fade-end, 0px)), transparent calc(100% - var(--fade-end, 0px) * 0.55))\";\n\nexport function OverflowScroller({\n  children,\n  label,\n  fade = 56,\n  controls = \"arrows\",\n  size = \"md\",\n  revealActive = true,\n  trackClassName,\n  className,\n  ...rest\n}: OverflowScrollerProps) {\n  const reduce = useReducedMotion();\n  const scroller = useRef<HTMLDivElement>(null);\n  const track = useRef<HTMLDivElement>(null);\n  const glide = useRef<AnimationPlaybackControls | null>(null);\n  const target = useRef<number | null>(null);\n  const edges = useScrollEdges(scroller);\n  const id = useId();\n\n  // The fades grow with the distance from each end instead of popping in, so\n  // the first pixel of scroll starts a 1px fade, not a 40px one.\n  useEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    let frame = 0;\n    const paint = () => {\n      const max = el.scrollWidth - el.clientWidth;\n      el.style.setProperty(\"--fade-start\", `${Math.min(fade, Math.max(0, el.scrollLeft))}px`);\n      el.style.setProperty(\"--fade-end\", `${Math.min(fade, Math.max(0, max - el.scrollLeft))}px`);\n    };\n    const onScroll = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(paint);\n    };\n    const stop = () => {\n      glide.current?.stop();\n      glide.current = null;\n      target.current = null;\n    };\n    const ro = new ResizeObserver(onScroll);\n    ro.observe(el);\n    if (track.current) ro.observe(track.current);\n    el.addEventListener(\"scroll\", onScroll, { passive: true });\n    // A hand on the scroller takes over from a glide in flight.\n    el.addEventListener(\"wheel\", stop, { passive: true });\n    el.addEventListener(\"touchstart\", stop, { passive: true });\n    el.addEventListener(\"pointerdown\", stop);\n    paint();\n    return () => {\n      cancelAnimationFrame(frame);\n      ro.disconnect();\n      el.removeEventListener(\"scroll\", onScroll);\n      el.removeEventListener(\"wheel\", stop);\n      el.removeEventListener(\"touchstart\", stop);\n      el.removeEventListener(\"pointerdown\", stop);\n      glide.current?.stop();\n    };\n  }, [fade]);\n\n  // The selected chip might start off screen: center it once, without motion.\n  useEffect(() => {\n    const el = scroller.current;\n    if (!revealActive || !el) return;\n    const active = el.querySelector<HTMLElement>('[aria-pressed=\"true\"], [aria-selected=\"true\"], [aria-current]:not([aria-current=\"false\"]), [data-active]');\n    if (!active) return;\n    const left = active.offsetLeft - (el.clientWidth - active.offsetWidth) / 2;\n    el.scrollLeft = Math.max(0, Math.min(el.scrollWidth - el.clientWidth, left));\n    // Mount only: later selections were made by the person looking at them.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const scrollTo = useCallback(\n    (to: number) => {\n      const el = scroller.current;\n      if (!el) return;\n      const left = Math.max(0, Math.min(el.scrollWidth - el.clientWidth, to));\n      glide.current?.stop();\n      if (reduce) {\n        el.scrollLeft = left;\n        return;\n      }\n      target.current = left;\n      glide.current = animate(el.scrollLeft, left, {\n        ...spring.sheet,\n        restDelta: 0.5,\n        onUpdate: (v) => (el.scrollLeft = v),\n        onComplete: () => {\n          glide.current = null;\n          target.current = null;\n        },\n      });\n    },\n    [reduce],\n  );\n\n  // Page-wise, landing on an item boundary: the first item cut off at the far\n  // edge becomes the first fully visible one, so nothing is skipped and no\n  // chip starts the new page sliced in half.\n  const page = (dir: 1 | -1) => {\n    const el = scroller.current;\n    const row = track.current;\n    if (!el || !row) return;\n    const x = target.current ?? el.scrollLeft;\n    const width = el.clientWidth;\n    const items = Array.from(row.children) as HTMLElement[];\n    const inset = fade;\n    let to: number | null = null;\n    if (dir > 0) {\n      const edge = x + width - inset;\n      const cut = items.find((it) => it.offsetLeft + it.offsetWidth > edge + 1);\n      if (cut) to = cut.offsetLeft - inset;\n    } else {\n      const edge = x + inset;\n      const cut = items.findLast((it) => it.offsetLeft < edge - 1);\n      if (cut) to = cut.offsetLeft + cut.offsetWidth - width + inset;\n    }\n    // One item wider than the view, or nothing cut: fall back to 80% of a page.\n    if (to === null || (dir > 0 ? to <= x + 8 : to >= x - 8)) to = x + dir * width * 0.8;\n    scrollTo(to);\n  };\n\n  const hasArrows = controls === \"arrows\";\n\n  return (\n    <div\n      role={label ? \"group\" : undefined}\n      aria-label={label}\n      data-size={size}\n      data-overflow-start={edges.start || undefined}\n      data-overflow-end={edges.end || undefined}\n      className={cn(\"group/os relative min-w-0\", className)}\n      {...rest}\n    >\n      <div\n        ref={scroller}\n        id={id}\n        // Keyboard focus lands clear of the fades, instantly.\n        onFocus={(e) => {\n          if (e.target !== e.currentTarget && !e.currentTarget.matches(\":active\")) (e.target as HTMLElement).scrollIntoView({ block: \"nearest\", inline: \"nearest\" });\n        }}\n        // Pressing an item that's half under a fade glides it fully into view.\n        onClick={(e) => {\n          const el = scroller.current;\n          const item = (e.target as HTMLElement).closest<HTMLElement>(\"[data-os-track] > *\");\n          if (!el || !item || !track.current?.contains(item)) return;\n          const x = el.scrollLeft;\n          if (item.offsetLeft < x + fade) scrollTo(item.offsetLeft - fade);\n          else if (item.offsetLeft + item.offsetWidth > x + el.clientWidth - fade) scrollTo(item.offsetLeft + item.offsetWidth - el.clientWidth + fade);\n        }}\n        // The 4px bleed on every side gives focus rings and shadows room inside the clip.\n        className=\"relative -m-1 overflow-x-auto overscroll-x-contain p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n        // The first 55% of each fade is fully clear, so nothing reads through under the arrow.\n        style={\n          {\n            scrollPaddingInline: fade,\n            maskImage: MASK,\n            WebkitMaskImage: MASK,\n          } as React.CSSProperties\n        }\n      >\n        <div ref={track} data-os-track=\"\" className={cn(\"flex w-max min-w-full items-center gap-1.5\", trackClassName)}>\n          {children}\n        </div>\n      </div>\n\n      {hasArrows && (\n        <>\n          <Arrow side=\"start\" size={size} shown={edges.start} controls={id} onPress={() => page(-1)} />\n          <Arrow side=\"end\" size={size} shown={edges.end} controls={id} onPress={() => page(1)} />\n        </>\n      )}\n    </div>\n  );\n}\n\nfunction Arrow({ side, size, shown, controls, onPress }: { side: \"start\" | \"end\"; size: \"sm\" | \"md\"; shown: boolean; controls: string; onPress: () => void }) {\n  const Icon = side === \"start\" ? ChevronLeft : ChevronRight;\n  return (\n    <button\n      type=\"button\"\n      // Out of the tab order: keyboard users move through the items themselves,\n      // and focus scrolls them into view. The arrows are for mice.\n      tabIndex={-1}\n      aria-label={side === \"start\" ? \"Scroll back\" : \"Scroll forward\"}\n      aria-controls={controls}\n      aria-hidden={!shown || undefined}\n      data-shown={shown || undefined}\n      onClick={onPress}\n      className={cn(\n        \"group/arrow absolute top-1/2 grid place-items-center rounded-full border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)] outline-none\",\n        \"transition-[opacity,translate,scale,background-color,border-color,color] duration-200 ease-out-expo hover:border-fg-4 hover:bg-hover hover:text-fg\",\n        \"active:scale-[0.92] active:duration-75 data-shown:active:scale-[0.92]\",\n        // Hidden: faded, shrunk and tucked 4px towards its edge; shown: in place.\n        \"pointer-events-none -translate-y-1/2 scale-90 opacity-0 data-shown:pointer-events-auto data-shown:scale-100 data-shown:opacity-100\",\n        \"pointer-coarse:hidden\",\n        size === \"sm\" ? \"size-6\" : \"size-7\",\n        side === \"start\" ? \"left-0 -translate-x-1 data-shown:translate-x-0\" : \"right-0 translate-x-1 data-shown:translate-x-0\",\n      )}\n    >\n      <Icon\n        size={size === \"sm\" ? 12 : 14}\n        className={cn(\"transition-transform duration-200 ease-out-expo\", side === \"start\" ? \"group-hover/arrow:-translate-x-px\" : \"group-hover/arrow:translate-x-px\")}\n      />\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/overflow-scroller"
}
