{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sticky-header",
  "type": "registry:ui",
  "title": "Sticky Header",
  "description": "Invisible at rest, gains its edge and blur once content slides beneath.",
  "categories": [
    "scroll"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json"
  ],
  "files": [
    {
      "path": "components/ui/sticky-header.tsx",
      "content": "\"use client\";\nimport { createContext, useContext, 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\n/** Pads the scroller so anchors and focus land below the header. Returns the undo. */\nfunction padScroller(scroller: HTMLElement, px: number) {\n  const before = scroller.style.scrollPaddingTop;\n  scroller.style.scrollPaddingTop = `${px}px`;\n  return () => {\n    scroller.style.scrollPaddingTop = before;\n  };\n}\n\n/**\n * True once the element has scrolled up past a line `offset` px below the top\n * of its scroll container. One IntersectionObserver, no scroll listeners.\n */\nexport function useScrolledPast(ref: Target, { root, offset = 0, enabled = true }: { root?: Target; offset?: number; enabled?: boolean } = {}) {\n  const [past, setPast] = useState(false);\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || !enabled) return;\n    const rootEl = root?.current ?? getScrollParent(el);\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        const top = (entry.rootBounds?.top ?? 0);\n        // Out of view below the fold is not \"past\"; only leaving over the top edge is.\n        setPast(!entry.isIntersecting && entry.boundingClientRect.bottom <= top + 1);\n      },\n      { root: rootEl, rootMargin: `${-Math.max(0, Math.round(offset))}px 0px 0px 0px`, threshold: [0, 1] },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ref, root, offset, enabled]);\n  return enabled && past;\n}\n\ntype Ctx = { height: number; offset: number; root?: Target };\nconst StickyHeaderContext = createContext<Ctx>({ height: 0, offset: 0 });\n\nexport type StickyHeaderProps = React.ComponentProps<\"header\"> & {\n  /** Distance from the top of the scroll container where the header sticks, in px. */\n  offset?: number;\n  /** The scroll container. Defaults to the nearest scrolling ancestor, or the page. */\n  scrollRoot?: Target;\n  /** Called when content starts or stops passing beneath the header. */\n  onScrolledChange?: (scrolled: boolean) => void;\n  /**\n   * Sets scroll-padding-top on the scroll container to the header's height, so\n   * anchor jumps and focused fields land below the header instead of under it.\n   */\n  scrollPadding?: boolean;\n};\n\nexport function StickyHeader({ offset = 0, scrollRoot, onScrolledChange, scrollPadding = true, className, style, children, ref, ...rest }: StickyHeaderProps) {\n  const sentinel = useRef<HTMLDivElement>(null);\n  const header = useRef<HTMLElement>(null);\n  const [height, setHeight] = useState(0);\n  // The sentinel sits where the header would be if it didn't stick. Once it\n  // passes the sticking line, content is sliding under the header.\n  const scrolled = useScrolledPast(sentinel, { root: scrollRoot, offset });\n\n  const notify = useRef(onScrolledChange);\n  const last = useRef(false);\n  useEffect(() => {\n    notify.current = onScrolledChange;\n  });\n  useEffect(() => {\n    if (scrolled === last.current) return;\n    last.current = scrolled;\n    notify.current?.(scrolled);\n  }, [scrolled]);\n\n  // Titles inside need the header's height to know when a heading has gone under it.\n  useEffect(() => {\n    const el = header.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setHeight(el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (!scrollPadding || !height) return;\n    return padScroller(scrollRoot?.current ?? getScrollParent(header.current) ?? document.documentElement, height + offset);\n  }, [scrollPadding, height, offset, scrollRoot]);\n\n  return (\n    <StickyHeaderContext value={{ height, offset, root: scrollRoot }}>\n      <div ref={sentinel} aria-hidden className=\"pointer-events-none -mb-px h-px\" />\n      <header\n        ref={(node) => {\n          header.current = node;\n          if (typeof ref === \"function\") return ref(node);\n          if (ref) ref.current = node;\n        }}\n        data-scrolled={scrolled ? \"\" : undefined}\n        style={{ top: offset, ...style }}\n        className={cn(\n          \"sticky z-(--z-sticky) border-b border-transparent bg-transparent\",\n          // The surface fades in over 200ms; the blur switches on with it (a backdrop\n          // filter is never animated) and is invisible until something is beneath.\n          \"transition-[background-color,border-color] duration-200 ease-out-quart\",\n          \"data-scrolled:border-line data-scrolled:bg-[color-mix(in_oklab,var(--sticky-header-bg,var(--frame))_97%,transparent)]\",\n          \"supports-backdrop-filter:data-scrolled:bg-[color-mix(in_oklab,var(--sticky-header-bg,var(--frame))_78%,transparent)] supports-backdrop-filter:data-scrolled:backdrop-blur-md supports-backdrop-filter:data-scrolled:backdrop-saturate-150\",\n          \"contrast-more:data-scrolled:border-line-2 contrast-more:data-scrolled:bg-[var(--sticky-header-bg,var(--frame))]\",\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </header>\n    </StickyHeaderContext>\n  );\n}\n\nexport type StickyHeaderTitleProps = Omit<React.ComponentProps<\"span\">, \"aria-hidden\"> & {\n  /** The heading in the content. The title slides in once it has gone under the header. */\n  watch: Target;\n};\n\n/**\n * A compact copy of the page title that rises into the header as the real\n * heading scrolls beneath it, and sinks back out when it returns.\n */\nexport function StickyHeaderTitle({ watch, className, children, ...rest }: StickyHeaderTitleProps) {\n  const { height, offset, root } = useContext(StickyHeaderContext);\n  const shown = useScrolledPast(watch, { root, offset: offset + height, enabled: height > 0 });\n  return (\n    <span\n      // The heading itself is already in the reading order; this is its echo.\n      aria-hidden\n      data-state={shown ? \"shown\" : \"hidden\"}\n      className={cn(\n        \"block min-w-0 truncate\",\n        \"transition-[opacity,translate,filter] duration-200 ease-out-expo motion-reduce:translate-y-0 motion-reduce:blur-none\",\n        \"data-[state=hidden]:pointer-events-none data-[state=hidden]:translate-y-1.5 data-[state=hidden]:opacity-0 data-[state=hidden]:blur-[2px] data-[state=hidden]:duration-150 data-[state=hidden]:ease-out-quart\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/sticky-header"
}
