{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll",
  "title": "Scroll Utilities",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Small hooks and helpers for scroll-aware UI: scrolled state, scroll direction, a scroll spy for in-page sections, and reduced-motion-aware scrolling to an element below a sticky header.",
  "files": [
    {
      "path": "registry/ballmac/lib/scroll.ts",
      "content": "// Ballmac UI: Scroll utilities. https://ui.ballmac.com/components/scroll\n\"use client\"\n\nimport * as React from \"react\"\n\n/** An element to scroll, or `null`/`undefined` for the window. */\nexport type ScrollContainer = React.RefObject<HTMLElement | null> | null | undefined\n\nfunction readY(container: ScrollContainer) {\n  const el = container?.current\n  return el ? el.scrollTop : typeof window === \"undefined\" ? 0 : window.scrollY\n}\n\nfunction scrollTarget(container: ScrollContainer): HTMLElement | Window {\n  return container?.current ?? window\n}\n\n/** True when the user asked the OS for less motion. Safe to call in event handlers. */\nexport function prefersReducedMotion() {\n  return typeof window !== \"undefined\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n}\n\n/**\n * Smoothly scrolls an element into view below a sticky header. Jumps instantly under reduced motion.\n * `offset` is the pixel space to leave above the element; `container` is the scrolling element (default: the page).\n */\nexport function scrollToElement(element: Element, options: { offset?: number; container?: ScrollContainer } = {}) {\n  const { offset = 0, container } = options\n  const box = container?.current\n  const behavior = prefersReducedMotion() ? \"auto\" : \"smooth\"\n  if (box) {\n    const top = element.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop - offset\n    box.scrollTo({ top, behavior })\n  } else {\n    window.scrollTo({ top: element.getBoundingClientRect().top + window.scrollY - offset, behavior })\n  }\n}\n\n/** Scrolls to the element with this id. Returns false when it does not exist. */\nexport function scrollToId(id: string, options: { offset?: number; container?: ScrollContainer } = {}) {\n  const el = (options.container?.current ?? document).querySelector?.(`[id=\"${CSS.escape(id)}\"]`) ?? document.getElementById(id)\n  if (!el) return false\n  scrollToElement(el, options)\n  return true\n}\n\nfunction useScrollListener(container: ScrollContainer, onScroll: (y: number) => void) {\n  const handler = React.useRef(onScroll)\n  React.useEffect(() => {\n    handler.current = onScroll\n  })\n  React.useEffect(() => {\n    const target = scrollTarget(container)\n    let frame = 0\n    const run = () => {\n      frame = 0\n      handler.current(readY(container))\n    }\n    const onEvent = () => {\n      if (!frame) frame = requestAnimationFrame(run)\n    }\n    target.addEventListener(\"scroll\", onEvent, { passive: true })\n    window.addEventListener(\"resize\", onEvent, { passive: true })\n    frame = requestAnimationFrame(run)\n    return () => {\n      target.removeEventListener(\"scroll\", onEvent)\n      window.removeEventListener(\"resize\", onEvent)\n      if (frame) cancelAnimationFrame(frame)\n    }\n  }, [container])\n}\n\n/** True once the page (or `container`) has scrolled past `threshold` pixels. False on the server and first render. */\nexport function useScrolled(threshold = 8, container?: ScrollContainer) {\n  const [scrolled, setScrolled] = React.useState(false)\n  useScrollListener(container, (y) => setScrolled(y > threshold))\n  return scrolled\n}\n\n/** \"down\" or \"up\" from the last meaningful scroll movement. Small jitters under `threshold` pixels are ignored. */\nexport function useScrollDirection(threshold = 10, container?: ScrollContainer) {\n  const [direction, setDirection] = React.useState<\"up\" | \"down\">(\"up\")\n  const last = React.useRef(0)\n  useScrollListener(container, (y) => {\n    const delta = y - last.current\n    if (Math.abs(delta) < threshold) return\n    last.current = y\n    setDirection(delta > 0 ? \"down\" : \"up\")\n  })\n  return direction\n}\n\n/**\n * Returns the id of the section the reader is in: the last id whose element top has passed `offset` pixels from the\n * top of the viewport (or container). At the very end of the page the last id wins, so short final sections can be reached.\n */\nexport function useScrollSpy(ids: string[], options: { offset?: number; container?: ScrollContainer } = {}) {\n  const { offset = 96, container } = options\n  const [active, setActive] = React.useState<string | undefined>(ids[0])\n  const key = ids.join(\"\\u0000\")\n  const list = React.useMemo(() => (key ? key.split(\"\\u0000\") : []), [key])\n  useScrollListener(container, () => {\n    if (!list.length) return\n    const box = container?.current\n    const boxTop = box ? box.getBoundingClientRect().top : 0\n    let current = list[0]\n    for (const id of list) {\n      const el = document.getElementById(id)\n      if (!el) continue\n      if (el.getBoundingClientRect().top - boxTop - offset <= 1) current = id\n      else break\n    }\n    const atEnd = box\n      ? box.scrollTop + box.clientHeight >= box.scrollHeight - 2\n      : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2\n    if (atEnd && list.length > 1 && (box ? box.scrollTop > 0 : window.scrollY > 0)) current = list[list.length - 1]\n    setActive(current)\n  })\n  return active\n}\n",
      "type": "registry:lib",
      "target": "@lib/ballmac/scroll.ts"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "scroll",
      "scrollspy",
      "sticky header",
      "reduced motion"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "Import useScrolled, useScrollDirection, useScrollSpy, scrollToId, scrollToElement and prefersReducedMotion from @/lib/ballmac/scroll. Pass a container ref to watch an element instead of the page.",
      "whenToUse": [
        "Sticky headers that change on scroll",
        "Highlighting the current section in a table of contents or tab bar",
        "Smooth in-page links that respect reduced motion"
      ],
      "whenNotToUse": [
        "Scroll-linked animation values (use Motion's useScroll)",
        "Virtual lists"
      ],
      "composesWith": [],
      "a11y": [],
      "customization": [
        "offset: pixels reserved for a sticky header",
        "container: a ref to a scrollable element"
      ]
    },
    "examples": [],
    "url": "https://ui.ballmac.com/components/scroll"
  },
  "categories": [
    "foundation"
  ],
  "type": "registry:lib"
}