{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "auto-hide-header",
  "type": "registry:ui",
  "title": "Auto-Hide Header",
  "description": "A sticky header that slides away as you scroll down and springs back the moment you scroll up.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/auto-hide-header.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useMotionValueEvent, useScroll } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface AutoHideHeaderSpring {\n  stiffness: number;\n  damping: number;\n  mass: number;\n}\n\nexport interface AutoHideHeaderProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"> {\n  children?: React.ReactNode;\n  /** Scrollable element to track. Defaults to the window. */\n  container?: React.RefObject<HTMLElement | null>;\n  /** Pixels scrolled in one direction before the header hides or reappears. */\n  tolerance?: number;\n  /** The header is always shown while the scroll position is within this many px of the top. */\n  offset?: number;\n  /** Spring for the slide. */\n  spring?: AutoHideHeaderSpring;\n  /** Gain a surface, hairline border and shadow once the page is scrolled. */\n  elevate?: boolean;\n  /** Keep the header visible regardless of scroll direction. */\n  pinned?: boolean;\n  /** Called when the header hides (false) or reappears (true). */\n  onVisibilityChange?: (visible: boolean) => void;\n}\n\n/**\n * A sticky header that slides away while you scroll down and returns as soon as you scroll up.\n * Scroll is read from motion values and the slide is a spring on a motion value, so scrolling\n * never re-renders React. It stays visible near the top, while pinned, and whenever focus is\n * inside it, so keyboard users can always reach it. With prefers-reduced-motion it still hides\n * and shows, but instantly, without sliding.\n */\nexport function AutoHideHeader({\n  children,\n  container,\n  tolerance = 8,\n  offset = 64,\n  spring = { stiffness: 500, damping: 42, mass: 0.8 },\n  elevate = true,\n  pinned = false,\n  onVisibilityChange,\n  className = \"\",\n  style,\n  onFocus,\n  onBlur,\n  ...props\n}: AutoHideHeaderProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const headerRef = React.useRef<HTMLElement>(null);\n  const y = useMotionValue(0);\n  const { scrollY } = useScroll({ container });\n\n  /** Scroll bookkeeping lives in a ref: none of it is rendered. */\n  const track = React.useRef({ hidden: false, lastY: 0, travel: 0, focusWithin: false });\n\n  const setHidden = (hidden: boolean) => {\n    const t = track.current;\n    const header = headerRef.current;\n    if (!header || t.hidden === hidden) return;\n    t.hidden = hidden;\n    header.setAttribute(\"data-state\", hidden ? \"hidden\" : \"visible\");\n    // Travel a little past the header's height so its shadow leaves too.\n    const target = hidden ? -(header.offsetHeight + 16) : 0;\n    if (reduceMotion) {\n      y.stop();\n      y.set(target);\n    } else {\n      animate(y, target, { type: \"spring\", ...spring });\n    }\n    onVisibilityChange?.(!hidden);\n  };\n\n  const maxScroll = () => {\n    const el = container?.current;\n    if (el) return el.scrollHeight - el.clientHeight;\n    return document.documentElement.scrollHeight - window.innerHeight;\n  };\n\n  const onScroll = (latest: number) => {\n    const header = headerRef.current;\n    if (!header) return;\n    const t = track.current;\n    // Ignore elastic overscroll above the top and below the bottom.\n    const current = Math.min(Math.max(latest, 0), Math.max(maxScroll(), 0));\n    const delta = current - t.lastY;\n    t.lastY = current;\n    header.setAttribute(\"data-elevated\", String(elevate && current > 1));\n\n    if (pinned || t.focusWithin || current <= offset) {\n      t.travel = 0;\n      setHidden(false);\n      return;\n    }\n    if (delta === 0) return;\n    // Accumulate travel in one direction; a direction change starts counting again.\n    t.travel = Math.sign(delta) === Math.sign(t.travel) ? t.travel + delta : delta;\n    if (t.travel > tolerance) setHidden(true);\n    else if (t.travel < -tolerance) setHidden(false);\n  };\n\n  useMotionValueEvent(scrollY, \"change\", onScroll);\n\n  // Initial sync (restored scroll position) and whenever the rules change.\n  const sync = React.useEffectEvent(() => {\n    const t = track.current;\n    t.lastY = scrollY.get();\n    headerRef.current?.setAttribute(\"data-elevated\", String(elevate && t.lastY > 1));\n    if (pinned || t.lastY <= offset) setHidden(false);\n  });\n  React.useEffect(() => {\n    sync();\n  }, [pinned, offset, elevate]);\n\n  return (\n    <motion.header\n      ref={headerRef}\n      data-state=\"visible\"\n      data-elevated=\"false\"\n      {...props}\n      onFocus={(event) => {\n        onFocus?.(event);\n        // Keyboard users never lose the header: focusing anything inside reveals it.\n        track.current.focusWithin = true;\n        setHidden(false);\n      }}\n      onBlur={(event) => {\n        onBlur?.(event);\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) track.current.focusWithin = false;\n      }}\n      style={{ ...style, y }}\n      className={`group/ahh sticky top-0 z-40 w-full border-b transition-[background-color,border-color,box-shadow] duration-300 motion-reduce:transition-none ${\n        elevate\n          ? \"border-transparent data-[elevated=true]:border-zinc-900/10 data-[elevated=true]:bg-white/85 data-[elevated=true]:shadow-[0_10px_30px_-20px_rgba(0,0,0,0.45)] data-[elevated=true]:backdrop-blur-md dark:data-[elevated=true]:border-white/10 dark:data-[elevated=true]:bg-zinc-950/80\"\n          : \"border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-950\"\n      } ${className}`}\n    >\n      {children}\n    </motion.header>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/auto-hide-header"
}
