{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-scroll-anchor",
  "title": "useScrollAnchor",
  "description": "A client-safe React hook that parks a selected item at a fixed anchor point in a scroll container, with a configurable easing and duration — the click-to-scroll behavior for navigation panels, command palettes, and step lists.",
  "files": [
    {
      "path": "registry/base/hooks/use-scroll-anchor.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useRef } from \"react\";\n\nconst DEFAULT_ANCHOR_RATIO = 1 / 3;\nconst DEFAULT_MIN_DURATION = 160;\nconst DEFAULT_MAX_DURATION = 320;\nconst DEFAULT_DISTANCE_DURATION_RATIO = 0.45;\nconst DEFAULT_TARGET_SELECTOR = \"[data-scroll-anchor]\";\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport type ScrollAnchorKey = string | number | null | undefined;\nexport type ScrollAnchorEasing = (progress: number) => number;\n\n/** Ready-made `progress → progress` easings to pass as `easing`. */\nexport const scrollAnchorEasings = {\n  easeOutQuart: (progress: number) => 1 - (1 - progress) ** 4,\n  easeOutCubic: (progress: number) => 1 - (1 - progress) ** 3,\n  easeInOutCubic: (progress: number) =>\n    progress < 0.5\n      ? 4 * progress * progress * progress\n      : 1 - (-2 * progress + 2) ** 3 / 2,\n  linear: (progress: number) => progress,\n} satisfies Record<string, ScrollAnchorEasing>;\n\nexport type UseScrollAnchorOptions<T extends HTMLElement> = {\n  /** When this changes while `enabled`, the active target is re-anchored. */\n  activeKey: ScrollAnchorKey;\n  /** Gate the behavior (e.g. only while a panel is open). Defaults to `true`. */\n  enabled?: boolean;\n  /**\n   * Where the target should land: `0` = top edge, `0.5` = vertical center,\n   * `1` = bottom edge. Defaults to `1 / 3` (upper third).\n   */\n  anchorRatio?: number;\n  /**\n   * Locate the element to anchor within the container. Defaults to the first\n   * `[data-scroll-anchor]` descendant.\n   */\n  getTarget?: (container: T) => HTMLElement | null;\n  /**\n   * Ease to the anchor when the key changes. On the first run after enabling —\n   * and whenever the user prefers reduced motion — the jump is instant. Defaults\n   * to `true`.\n   */\n  animate?: boolean;\n  /** Easing for the animated scroll. Defaults to `scrollAnchorEasings.easeOutQuart`. */\n  easing?: ScrollAnchorEasing;\n  /**\n   * Animation length in ms — a fixed number, or a function of the scroll\n   * distance in px. Omit for a distance-proportional ramp (160–320ms).\n   */\n  duration?: number | ((distance: number) => number);\n  /** Jump instantly when the user prefers reduced motion. Defaults to `true`. */\n  respectReducedMotion?: boolean;\n  /** Fires once the target reaches its anchor (after animating or jumping). */\n  onSettled?: () => void;\n};\n\nexport type UseScrollAnchorResult<T extends HTMLElement> = {\n  /** Attach to the scrollable container. */\n  containerRef: React.RefObject<T | null>;\n  /** Imperatively re-anchor the active target (e.g. after async content loads). */\n  scrollActiveIntoView: (options?: { animate?: boolean }) => void;\n};\n\n/**\n * Keeps a selected item parked at a fixed anchor point within a scroll\n * container — the \"click an item, glide it to the upper third\" behavior common\n * to navigation panels, command palettes, and step lists.\n *\n * Unlike the native `Element.scrollIntoView({ block: \"nearest\" })`, which only\n * guarantees visibility, this always lands the target at `anchorRatio` and uses\n * an interruptible eased animation. The first placement after enabling is\n * instant (no scroll-from-nowhere), later selection changes glide, and\n * reduced-motion users jump (unless you opt out).\n *\n * Options are read live, so passing inline `getTarget` / `easing` / `onSettled`\n * is safe — the scroll only re-runs when `activeKey` or `enabled` change.\n *\n * @example\n *   const { containerRef } = useScrollAnchor<HTMLDivElement>({\n *     activeKey: selectedId,\n *     enabled: open,\n *     getTarget: (c) => c.querySelector('[aria-current=\"page\"]'),\n *     easing: scrollAnchorEasings.easeInOutCubic,\n *     duration: (distance) => Math.min(120 + distance * 0.5, 400),\n *   });\n *   return <div ref={containerRef} className=\"overflow-y-auto\">…</div>;\n */\nexport function useScrollAnchor<T extends HTMLElement = HTMLElement>(\n  options: UseScrollAnchorOptions<T>,\n): UseScrollAnchorResult<T> {\n  const { activeKey, enabled = true } = options;\n\n  const containerRef = useRef<T | null>(null);\n  const frameRef = useRef<number | null>(null);\n  const lastKeyRef = useRef<ScrollAnchorKey>(activeKey);\n  // Read options live so inline callbacks don't churn the effect below. Synced\n  // in a layout effect (declared first, so it runs before the scroll effect).\n  const optionsRef = useRef(options);\n\n  useIsomorphicLayoutEffect(() => {\n    optionsRef.current = options;\n  });\n\n  const cancelAnimation = useCallback(() => {\n    if (frameRef.current !== null) {\n      cancelAnimationFrame(frameRef.current);\n      frameRef.current = null;\n    }\n  }, []);\n\n  const scrollActiveIntoView = useCallback(\n    (overrides?: { animate?: boolean }) => {\n      const container = containerRef.current;\n\n      if (!container) {\n        return;\n      }\n\n      const {\n        anchorRatio = DEFAULT_ANCHOR_RATIO,\n        getTarget,\n        animate = true,\n        easing = scrollAnchorEasings.easeOutQuart,\n        duration,\n        respectReducedMotion = true,\n        onSettled,\n      } = optionsRef.current;\n\n      const target = getTarget\n        ? getTarget(container)\n        : container.querySelector<HTMLElement>(DEFAULT_TARGET_SELECTOR);\n\n      if (!target) {\n        return;\n      }\n\n      const maxScrollTop = Math.max(\n        container.scrollHeight - container.clientHeight,\n        0,\n      );\n      const containerRect = container.getBoundingClientRect();\n      const targetRect = target.getBoundingClientRect();\n      const targetCenter =\n        targetRect.top -\n        containerRect.top +\n        container.scrollTop +\n        targetRect.height / 2;\n      const nextScrollTop = clamp(\n        targetCenter - container.clientHeight * anchorRatio,\n        0,\n        maxScrollTop,\n      );\n\n      cancelAnimation();\n\n      const reduceMotion = respectReducedMotion && prefersReducedMotion();\n      const smooth = (overrides?.animate ?? animate) && !reduceMotion;\n      const distance = nextScrollTop - container.scrollTop;\n      const totalDuration = Math.max(0, resolveDuration(duration, distance));\n\n      if (!smooth || Math.abs(distance) < 1 || totalDuration === 0) {\n        container.scrollTop = nextScrollTop;\n        onSettled?.();\n        return;\n      }\n\n      const startScrollTop = container.scrollTop;\n      let startedAt: number | null = null;\n\n      const stepFrame = (now: number) => {\n        if (startedAt === null) {\n          startedAt = now;\n        }\n\n        const progress = clamp((now - startedAt) / totalDuration, 0, 1);\n        container.scrollTop = startScrollTop + distance * easing(progress);\n\n        if (progress < 1) {\n          frameRef.current = requestAnimationFrame(stepFrame);\n          return;\n        }\n\n        container.scrollTop = nextScrollTop;\n        frameRef.current = null;\n        onSettled?.();\n      };\n\n      frameRef.current = requestAnimationFrame(stepFrame);\n    },\n    [cancelAnimation],\n  );\n\n  useIsomorphicLayoutEffect(() => {\n    if (!enabled) {\n      // Stay in sync while gated so the next enable anchors instantly.\n      cancelAnimation();\n      lastKeyRef.current = activeKey;\n      return;\n    }\n\n    const keyChanged = lastKeyRef.current !== activeKey;\n    scrollActiveIntoView({ animate: keyChanged });\n    lastKeyRef.current = activeKey;\n  }, [activeKey, cancelAnimation, enabled, scrollActiveIntoView]);\n\n  useEffect(() => cancelAnimation, [cancelAnimation]);\n\n  return { containerRef, scrollActiveIntoView };\n}\n\nfunction resolveDuration(\n  duration: number | ((distance: number) => number) | undefined,\n  distance: number,\n) {\n  if (typeof duration === \"function\") {\n    return duration(Math.abs(distance));\n  }\n\n  if (typeof duration === \"number\") {\n    return duration;\n  }\n\n  return clamp(\n    DEFAULT_MIN_DURATION + Math.abs(distance) * DEFAULT_DISTANCE_DURATION_RATIO,\n    DEFAULT_MIN_DURATION,\n    DEFAULT_MAX_DURATION,\n  );\n}\n\nfunction prefersReducedMotion() {\n  return (\n    typeof window !== \"undefined\" &&\n    typeof window.matchMedia === \"function\" &&\n    window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  );\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  if (max <= min) {\n    return min;\n  }\n\n  return Math.min(Math.max(value, min), max);\n}\n",
      "type": "registry:hook",
      "target": "@hooks/use-scroll-anchor.ts"
    }
  ],
  "meta": {
    "tags": [
      "scroll-anchor",
      "scroll-into-view",
      "active-item",
      "eased-scroll",
      "easing"
    ],
    "effects": [
      "scroll-anchor",
      "eased-scroll"
    ]
  },
  "categories": [
    "motion"
  ],
  "type": "registry:hook"
}