{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hide-on-scroll","type":"registry:ui","title":"Hide on Scroll","description":"Toolbar yields to the content.","dependencies":["motion"],"categories":["scroll"],"docs":"https://www.interior.dev/docs/hide-on-scroll","files":[{"path":"registry/interior/hide-on-scroll.tsx","type":"registry:ui","target":"components/interior/hide-on-scroll.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst DISCLOSE = { type: \"spring\", stiffness: 150, damping: 27, mass: 1 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\n\nexport type UseHideOnScrollOptions = {\n  hideAfter?: number;\n  revealAfter?: number;\n  topGuard?: number;\n  pinned?: boolean;\n  disabled?: boolean;\n};\n\nexport type UseHideOnScrollResult<T extends HTMLElement> = {\n  ref: React.RefObject<T | null>;\n  hidden: boolean;\n  atTop: boolean;\n};\n\nexport function useHideOnScroll<T extends HTMLElement = HTMLDivElement>({\n  hideAfter = 14,\n  revealAfter = 10,\n  topGuard = 24,\n  pinned = false,\n  disabled = false,\n}: UseHideOnScrollOptions = {}): UseHideOnScrollResult<T> {\n  const ref = useRef<T | null>(null);\n  const frame = useRef(0);\n  const last = useRef(0);\n  const accum = useRef(0);\n\n  const held = useRef(pinned || disabled);\n  held.current = pinned || disabled;\n\n  const [hidden, setHidden] = useState(false);\n  const [atTop, setAtTop] = useState(true);\n\n  const down = Math.max(1, hideAfter);\n  const up = Math.max(1, revealAfter);\n  const guard = Math.max(0, topGuard);\n\n  useEffect(() => {\n    if (!pinned && !disabled) return;\n    accum.current = 0;\n    setHidden(false);\n  }, [pinned, disabled]);\n\n  useEffect(() => {\n    const el = ref.current;\n    const target: EventTarget = el ?? window;\n\n    const readY = () => (el ? el.scrollTop : window.scrollY);\n    const readMax = () =>\n      el\n        ? el.scrollHeight - el.clientHeight\n        : document.documentElement.scrollHeight - window.innerHeight;\n\n    const evaluate = () => {\n      frame.current = 0;\n\n      const max = readMax();\n      const y = readY();\n\n      if (max <= guard) {\n        accum.current = 0;\n        last.current = y;\n        setAtTop((prev) => (prev ? prev : true));\n        setHidden((prev) => (prev ? false : prev));\n        return;\n      }\n\n      if (y < 0 || y > max) return;\n\n      const dy = y - last.current;\n      last.current = y;\n\n      const top = y <= guard;\n      setAtTop((prev) => (prev === top ? prev : top));\n\n      if (held.current || top) {\n        accum.current = 0;\n        setHidden((prev) => (prev ? false : prev));\n        return;\n      }\n\n      if (dy === 0) return;\n      if (dy > 0 !== accum.current > 0) accum.current = 0;\n      accum.current += dy;\n\n      if (accum.current >= down) {\n        accum.current = 0;\n        setHidden((prev) => (prev ? prev : true));\n      } else if (accum.current <= -up) {\n        accum.current = 0;\n        setHidden((prev) => (prev ? false : prev));\n      }\n    };\n\n    const schedule = () => {\n      if (frame.current) return;\n      frame.current = requestAnimationFrame(evaluate);\n    };\n\n    last.current = readY();\n    evaluate();\n\n    target.addEventListener(\"scroll\", schedule, { passive: true });\n    window.addEventListener(\"resize\", schedule);\n\n    let observer: ResizeObserver | null = null;\n    if (el && typeof ResizeObserver !== \"undefined\") {\n      observer = new ResizeObserver(schedule);\n      observer.observe(el);\n    }\n\n    return () => {\n      target.removeEventListener(\"scroll\", schedule);\n      window.removeEventListener(\"resize\", schedule);\n      observer?.disconnect();\n      if (frame.current) cancelAnimationFrame(frame.current);\n      frame.current = 0;\n    };\n  }, [down, up, guard]);\n\n  return { ref, hidden, atTop };\n}\n\nexport type HideOnScrollProps = {\n  bar: React.ReactNode;\n  children: React.ReactNode;\n  barHeight?: number;\n  hideAfter?: number;\n  revealAfter?: number;\n  topGuard?: number;\n  pinned?: boolean;\n  maxHeight?: number;\n  label?: string;\n  onHiddenChange?: (hidden: boolean) => void;\n  className?: string;\n};\n\nexport function HideOnScroll({\n  bar,\n  children,\n  barHeight = 44,\n  hideAfter = 14,\n  revealAfter = 10,\n  topGuard = 24,\n  pinned = false,\n  maxHeight = 320,\n  label = \"Scrollable content\",\n  onHiddenChange,\n  className = \"\",\n}: HideOnScrollProps) {\n  const [focusWithin, setFocusWithin] = useState(false);\n\n  const { ref, hidden, atTop } = useHideOnScroll<HTMLDivElement>({\n    hideAfter,\n    revealAfter,\n    topGuard,\n    pinned: pinned || focusWithin,\n  });\n\n  const reduced = useReducedMotion();\n  const slide = reduced ? { duration: 0 } : DISCLOSE;\n  const fade = reduced ? { duration: 0 } : CROSSFADE;\n\n  const seen = useRef(hidden);\n  useEffect(() => {\n    if (seen.current === hidden) return;\n    seen.current = hidden;\n    onHiddenChange?.(hidden);\n  }, [hidden, onHiddenChange]);\n\n  return (\n    <div\n      className={`relative w-full min-w-0 overflow-hidden rounded-[14px] border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)] ${className}`}\n    >\n      <motion.div\n        data-hidden={hidden ? \"true\" : \"false\"}\n        onFocus={() => setFocusWithin(true)}\n        onBlur={() => setFocusWithin(false)}\n        style={{ height: barHeight }}\n        initial={false}\n        animate={{ y: hidden ? -barHeight : 0 }}\n        transition={slide}\n        className=\"absolute inset-x-0 top-0 z-10 flex items-center gap-2 bg-white px-3 dark:bg-[#1D1D1A]\"\n      >\n        {bar}\n\n        <motion.span\n          aria-hidden\n          initial={false}\n          animate={{ opacity: atTop ? 0 : 1 }}\n          transition={fade}\n          className=\"pointer-events-none absolute inset-x-0 bottom-0 h-px bg-stone-200 dark:bg-white/[0.16]\"\n        />\n      </motion.div>\n      <div\n        ref={ref}\n\n        // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex\n        tabIndex={0}\n        role=\"region\"\n        aria-label={label}\n        style={{ maxHeight, scrollPaddingTop: barHeight + 8 }}\n        className=\"overflow-y-auto overscroll-y-contain outline-none [scrollbar-gutter:stable] focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.06] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n      >\n        <div aria-hidden style={{ height: barHeight }} />\n        <div\n          aria-hidden\n          className=\"pointer-events-none sticky top-0 -mb-5 h-5 bg-gradient-to-b from-white to-transparent dark:from-[#1D1D1A]\"\n        />\n        {children}\n        <div\n          aria-hidden\n          className=\"pointer-events-none sticky bottom-0 -mt-5 h-5 bg-gradient-to-t from-white to-transparent dark:from-[#1D1D1A]\"\n        />\n      </div>\n    </div>\n  );\n}\n"}]}