{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hide-on-scroll",
  "type": "registry:ui",
  "title": "Hide on Scroll",
  "description": "Steps aside while you read down, returns on a deliberate scroll up.",
  "categories": [
    "scroll"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json"
  ],
  "files": [
    {
      "path": "components/ui/hide-on-scroll.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/** The nearest ancestor that scrolls vertically, or null for the page itself. */\nfunction getScrollParent(el: HTMLElement | null): HTMLElement | null {\n  for (let node = el?.parentElement; node && node !== document.body && node !== document.documentElement; node = node.parentElement) {\n    if (/(auto|scroll|overlay)/.test(getComputedStyle(node).overflowY)) return node;\n  }\n  return null;\n}\n\ntype Target = React.RefObject<HTMLElement | null>;\n\nfunction jumpTo(scroller: HTMLElement | null, y: number) {\n  if (scroller) scroller.scrollTop = y;\n  else window.scrollTo({ top: y, behavior: \"instant\" });\n}\n\nexport type HideOnScrollOptions = {\n  /** The scroll container. Defaults to the nearest scrolling ancestor, or the page. */\n  scrollRoot?: Target;\n  /** Pixels of continuous downward scroll before it hides. */\n  hideAfter?: number;\n  /** Pixels of continuous upward scroll before it returns. */\n  revealAfter?: number;\n  /** An upward flick faster than this (px per ms) brings it back at once. */\n  revealVelocity?: number;\n  /** Always shown within this many px of the top. Defaults to the element's own height. */\n  topZone?: number;\n  /** Come back when the scroll reaches the end, where there's nothing left to read. */\n  revealAtEnd?: boolean;\n  /** Keep it shown, e.g. while a menu inside it is open. */\n  pinned?: boolean;\n};\n\n/**\n * The hide/reveal decision on its own. Downward travel accumulates until\n * `hideAfter`; upward travel needs `revealAfter`, unless it's a fast flick,\n * so slow reading drift never pops the bar back over the text.\n */\nexport function useHideOnScroll(\n  ref: Target,\n  { scrollRoot, hideAfter = 16, revealAfter = 56, revealVelocity = 0.9, topZone, revealAtEnd = true, pinned = false }: HideOnScrollOptions = {},\n) {\n  const [hidden, setHidden] = useState(false);\n  const [atTop, setAtTop] = useState(true);\n  const [focused, setFocused] = useState(false);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const scroller = scrollRoot?.current ?? getScrollParent(el);\n    const source: HTMLElement | Window = scroller ?? window;\n    const read = () => (scroller ? scroller.scrollTop : window.scrollY);\n    const limit = () => (scroller ? scroller.scrollHeight - scroller.clientHeight : document.documentElement.scrollHeight - window.innerHeight);\n\n    let lastY = read();\n    let lastT = performance.now();\n    let travel = 0;\n    let velocity = 0;\n    let frame = 0;\n    let hiddenNow = false;\n    const hide = (next: boolean) => {\n      hiddenNow = next;\n      setHidden(next);\n    };\n\n    const update = () => {\n      frame = 0;\n      const max = Math.max(0, limit());\n      // Clamp so iOS rubber-banding past either end isn't read as a direction change.\n      const y = Math.min(Math.max(read(), 0), max);\n      const now = performance.now();\n      const dy = y - lastY;\n      const dt = Math.max(1, now - lastT);\n      // A pause longer than 100ms starts a new gesture; otherwise smooth the speed.\n      velocity = dt > 100 ? dy / dt : velocity * 0.5 + (dy / dt) * 0.5;\n      lastY = y;\n      lastT = now;\n\n      const zone = topZone ?? el.offsetHeight;\n      setAtTop(y <= 1);\n      if (y <= zone || (revealAtEnd && max > zone && y >= max - 2)) {\n        travel = 0;\n        hide(false);\n        return;\n      }\n      if (dy === 0) return;\n      // Travel resets whenever the direction flips.\n      travel = Math.sign(dy) === Math.sign(travel) ? travel + dy : dy;\n      if (travel > hideAfter) hide(true);\n      else if (travel < -revealAfter || velocity < -revealVelocity) hide(false);\n    };\n\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(update);\n    };\n\n    // Keyboard users tabbing into a hidden bar get it back, and keep it while inside.\n    // The browser has already scrolled to bring the off-screen control into view;\n    // undo that jump, since the bar is coming back to where they are.\n    const onIn = () => {\n      if (hiddenNow && read() !== lastY) jumpTo(scroller, lastY);\n      travel = 0;\n      hide(false);\n      setFocused(true);\n    };\n    const onOut = (e: FocusEvent) => {\n      if (!el.contains(e.relatedTarget as Node | null)) setFocused(false);\n    };\n\n    source.addEventListener(\"scroll\", onScroll, { passive: true });\n    el.addEventListener(\"focusin\", onIn);\n    el.addEventListener(\"focusout\", onOut);\n    // Read the starting position on the next frame, e.g. after scroll restoration.\n    frame = requestAnimationFrame(update);\n    return () => {\n      source.removeEventListener(\"scroll\", onScroll);\n      el.removeEventListener(\"focusin\", onIn);\n      el.removeEventListener(\"focusout\", onOut);\n      cancelAnimationFrame(frame);\n    };\n  }, [ref, scrollRoot, hideAfter, revealAfter, revealVelocity, topZone, revealAtEnd]);\n\n  return { hidden: hidden && !pinned && !focused, atTop };\n}\n\nexport type HideOnScrollProps = React.ComponentProps<\"div\"> &\n  HideOnScrollOptions & {\n    /** Which edge it lives on. A top bar slides up and away; a bottom bar slides down. */\n    edge?: \"top\" | \"bottom\";\n    /** Called when it hides or returns. */\n    onHiddenChange?: (hidden: boolean) => void;\n  };\n\nexport function HideOnScroll({\n  edge = \"top\",\n  scrollRoot,\n  hideAfter,\n  revealAfter,\n  revealVelocity,\n  topZone,\n  revealAtEnd,\n  pinned,\n  onHiddenChange,\n  className,\n  children,\n  ref,\n  ...rest\n}: HideOnScrollProps) {\n  const el = useRef<HTMLDivElement>(null);\n  const { hidden, atTop } = useHideOnScroll(el, { scrollRoot, hideAfter, revealAfter, revealVelocity, topZone, revealAtEnd, pinned });\n\n  const notify = useRef(onHiddenChange);\n  const last = useRef(false);\n  useEffect(() => {\n    notify.current = onHiddenChange;\n  });\n  useEffect(() => {\n    if (hidden === last.current) return;\n    last.current = hidden;\n    notify.current?.(hidden);\n  }, [hidden]);\n\n  return (\n    <div\n      ref={(node) => {\n        el.current = node;\n        if (typeof ref === \"function\") return ref(node);\n        if (ref) ref.current = node;\n      }}\n      data-edge={edge}\n      data-hidden={hidden ? \"\" : undefined}\n      data-at-top={atTop ? \"\" : undefined}\n      className={cn(\n        \"sticky z-(--z-sticky)\",\n        edge === \"top\" ? \"top-0 border-b\" : \"bottom-0 border-t\",\n        // Returning is an entrance: 320ms expo ease-out. Leaving moves on screen, so\n        // it's quicker and eases in and out: 220ms.\n        \"transition-[translate,background-color,border-color] duration-320 ease-out-expo\",\n        \"data-hidden:duration-220 data-hidden:ease-in-out-quart\",\n        edge === \"top\" ? \"data-hidden:-translate-y-[calc(100%+2px)]\" : \"data-hidden:translate-y-[calc(100%+2px)]\",\n        // Translucent over a blur. Bottom bars sit under the thumb and small labels, so\n        // they are more opaque. A top bar is plain at the very top and gains its\n        // surface once content is beneath.\n        \"border-line bg-[color-mix(in_oklab,var(--hide-on-scroll-bg,var(--frame))_97%,transparent)] supports-backdrop-filter:backdrop-blur-md supports-backdrop-filter:backdrop-saturate-150\",\n        edge === \"top\"\n          ? \"supports-backdrop-filter:bg-[color-mix(in_oklab,var(--hide-on-scroll-bg,var(--frame))_80%,transparent)]\"\n          : \"supports-backdrop-filter:bg-[color-mix(in_oklab,var(--hide-on-scroll-bg,var(--frame))_90%,transparent)]\",\n        edge === \"top\" && \"data-at-top:border-transparent data-at-top:bg-transparent\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/hide-on-scroll"
}
