{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "navbar",
  "type": "registry:ui",
  "title": "Navbar",
  "description": "Top bar that compacts on scroll while a pill follows the pointer home.",
  "categories": [
    "app-nav"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/navbar.tsx",
      "content": "\"use client\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { spring } from \"@/lib/motion\";\n\n/* -------------------------------------------------------------------------------------------------\n * Scroll state\n * -----------------------------------------------------------------------------------------------*/\n\nexport type NavbarScrollState = { compact: boolean; hidden: boolean };\n\n/**\n * Whether a page has scrolled past `compactAfter`, and whether the bar should step\n * aside because the reader is scrolling down through content. Reads the window, or\n * `target` when the page scrolls inside an element. Updates at most once a frame and\n * only re-renders when either flag flips.\n */\nexport function useNavbarScroll({\n  target,\n  compactAfter = 8,\n  hideOnScroll = false,\n}: { target?: React.RefObject<HTMLElement | null>; compactAfter?: number; hideOnScroll?: boolean } = {}) {\n  const [state, setState] = useState<NavbarScrollState>({ compact: false, hidden: false });\n\n  useEffect(() => {\n    const el = target?.current ?? null;\n    const read = () => (el ? el.scrollTop : window.scrollY);\n    let last = read();\n    let travel = 0;\n    let raf = 0;\n    const update = () => {\n      raf = 0;\n      const y = read();\n      const delta = y - last;\n      last = y;\n      // Direction has to persist for a few pixels before the bar reacts, so a trackpad's\n      // tremor at rest never flickers it.\n      travel = Math.sign(delta) === Math.sign(travel) ? travel + delta : delta;\n      setState((prev) => {\n        const compact = y > compactAfter;\n        let hidden = prev.hidden;\n        if (!hideOnScroll || y < 120) hidden = false;\n        else if (travel > 12) hidden = true;\n        else if (travel < -6) hidden = false;\n        return prev.compact === compact && prev.hidden === hidden ? prev : { compact, hidden };\n      });\n    };\n    const onScroll = () => {\n      if (!raf) raf = requestAnimationFrame(update);\n    };\n    const source: HTMLElement | Window = el ?? window;\n    source.addEventListener(\"scroll\", onScroll, { passive: true });\n    onScroll();\n    return () => {\n      source.removeEventListener(\"scroll\", onScroll);\n      cancelAnimationFrame(raf);\n    };\n  }, [target, compactAfter, hideOnScroll]);\n\n  return state;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Root\n * -----------------------------------------------------------------------------------------------*/\n\nconst NavbarContext = createContext<NavbarScrollState>({ compact: false, hidden: false });\n\nexport type NavbarProps = React.ComponentProps<\"div\"> & {\n  /** The element the page scrolls in. Defaults to the window. */\n  target?: React.RefObject<HTMLElement | null>;\n  /** Pixels of scroll before the bar compacts and takes on its backdrop. */\n  compactAfter?: number;\n  /** Slide the bar away while scrolling down past 120px, and back on the first scroll up. */\n  hideOnScroll?: boolean;\n  /** Where the bar's content stops growing. */\n  maxWidth?: number;\n};\n\n/**\n * A sticky top bar. It reserves 56px in the page, and when the page scrolls it\n * compacts to 48px inside that space, so nothing below it ever moves.\n */\nexport function Navbar({ target, compactAfter = 8, hideOnScroll = false, maxWidth = 1120, className, children, ...rest }: NavbarProps) {\n  const scroll = useNavbarScroll({ target, compactAfter, hideOnScroll });\n  const [focusWithin, setFocusWithin] = useState(false);\n  const hidden = scroll.hidden && !focusWithin;\n\n  return (\n    <NavbarContext.Provider value={scroll}>\n      <div className={cn(\"@container sticky top-0 z-(--z-sticky) h-14\", className)} {...rest}>\n        <header\n          data-compact={scroll.compact || undefined}\n          data-hidden={hidden || undefined}\n          // A keyboard user tabbing into a hidden bar brings it back.\n          onFocus={() => setFocusWithin(true)}\n          onBlur={(e) => {\n            if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusWithin(false);\n          }}\n          className={cn(\n            \"group/nav absolute inset-x-0 top-0 flex h-14 items-center border-b border-transparent\",\n            // The backdrop never animates its blur; only the tint and the hairline fade in over it.\n            \"backdrop-blur-md backdrop-saturate-150 transition-[height,background-color,border-color,translate] duration-220 ease-out-quart\",\n            \"data-compact:h-12 data-compact:border-line data-compact:bg-frame/80\",\n            \"data-hidden:-translate-y-full data-hidden:duration-260 data-hidden:ease-in-out-quart\",\n          )}\n        >\n          <div className=\"mx-auto flex h-full w-full min-w-0 items-center gap-2 px-4 @min-[48rem]:px-6\" style={{ maxWidth }}>\n            {children}\n          </div>\n        </header>\n      </div>\n    </NavbarContext.Provider>\n  );\n}\n\n/** Reads the bar's scroll state from inside it: `{ compact, hidden }`. */\nexport function useNavbar() {\n  return useContext(NavbarContext);\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Brand and actions\n * -----------------------------------------------------------------------------------------------*/\n\nexport type NavbarBrandProps = useRender.ComponentProps<\"a\">;\n\n/** The logo link. It settles 6% smaller as the bar compacts. */\nexport function NavbarBrand({ render, className, ...rest }: NavbarBrandProps) {\n  return useRender({\n    defaultTagName: \"a\",\n    render,\n    props: mergeProps<\"a\">(\n      {\n        className: cn(\n          \"mr-2 flex shrink-0 origin-left items-center gap-2 rounded-md text-[14px] font-medium tracking-[-0.02em] text-fg no-underline outline-none\",\n          \"transition-[scale] duration-220 ease-out-quart group-data-compact/nav:scale-[0.94] active:scale-[0.97]\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-4 focus-visible:outline-fg-3\",\n          className,\n        ),\n      },\n      rest,\n    ),\n  });\n}\n\nexport type NavbarActionsProps = React.ComponentProps<\"div\">;\n\n/** The right side: sign in, the primary call to action, an avatar. */\nexport function NavbarActions({ className, ...rest }: NavbarActionsProps) {\n  return <div className={cn(\"ml-auto flex shrink-0 items-center gap-1.5\", className)} {...rest} />;\n}\n\nexport type NavbarMobileProps = React.ComponentProps<\"div\">;\n\n/** Shown only when the bar is too narrow for its links: put your mobile menu here. */\nexport function NavbarMobile({ className, ...rest }: NavbarMobileProps) {\n  return <div className={cn(\"flex shrink-0 items-center @min-[40rem]:hidden\", className)} {...rest} />;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Links and the pill that follows the pointer\n * -----------------------------------------------------------------------------------------------*/\n\ntype PillContextValue = { enter: (el: HTMLElement) => void };\nconst PillContext = createContext<PillContextValue | null>(null);\n\nexport type NavbarLinksProps = React.ComponentProps<\"ul\"> & { \"aria-label\"?: string };\n\n/**\n * The link row. One pill rests under the current page, follows the pointer across\n * the other links, and springs home when the pointer leaves. Hidden below 640px of\n * bar width, where NavbarMobile takes over.\n */\nexport function NavbarLinks({ className, children, \"aria-label\": label = \"Main\", ...rest }: NavbarLinksProps) {\n  const reduce = !!useReducedMotion();\n  const listRef = useRef<HTMLUListElement>(null);\n  const x = useMotionValue(0);\n  const width = useMotionValue(0);\n  const [on, setOn] = useState(false);\n  const shown = useRef(false);\n  const hovering = useRef(false);\n\n  const moveTo = useCallback(\n    (el: HTMLElement | null) => {\n      if (!el) {\n        shown.current = false;\n        setOn(false);\n        return;\n      }\n      if (shown.current && !reduce) {\n        animate(x, el.offsetLeft, spring.follow);\n        animate(width, el.offsetWidth, spring.follow);\n      } else {\n        x.jump(el.offsetLeft);\n        width.jump(el.offsetWidth);\n      }\n      shown.current = true;\n      setOn(true);\n    },\n    [reduce, x, width],\n  );\n\n  const home = useCallback(() => moveTo(listRef.current?.querySelector<HTMLElement>(\"[aria-current=page]\") ?? null), [moveTo]);\n\n  // Whenever the current page changes (a click, a route change, a scroll spy), the\n  // pill goes home, unless the pointer is holding it somewhere else right now.\n  useLayoutEffect(() => {\n    if (!hovering.current) home();\n  });\n\n  // Fonts loading or the bar resizing moves the links; keep the pill under them.\n  useEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n    const ro = new ResizeObserver(() => {\n      if (!hovering.current) home();\n    });\n    ro.observe(list);\n    return () => ro.disconnect();\n  }, [home]);\n\n  return (\n    <PillContext.Provider value={{ enter: (el) => ((hovering.current = true), moveTo(el)) }}>\n      <nav aria-label={label} className=\"hidden min-w-0 @min-[40rem]:block\">\n        <ul\n          ref={listRef}\n          onPointerLeave={() => {\n            hovering.current = false;\n            home();\n          }}\n          className={cn(\"relative flex items-center\", className)}\n          {...rest}\n        >\n          <motion.li\n            aria-hidden\n            className=\"pointer-events-none absolute left-0 top-1/2 -mt-4 h-8 rounded-full bg-hover\"\n            style={{ x, width }}\n            initial={false}\n            animate={{ opacity: on ? 1 : 0 }}\n            transition={{ duration: on ? 0.12 : 0.15, ease: \"easeOut\" }}\n          />\n          {children}\n        </ul>\n      </nav>\n    </PillContext.Provider>\n  );\n}\n\nexport type NavbarLinkProps = useRender.ComponentProps<\"a\"> & {\n  /** Marks the current page: aria-current, full-strength text, and the pill's home. */\n  active?: boolean;\n};\n\nexport function NavbarLink({ active = false, render, className, onPointerEnter, ...rest }: NavbarLinkProps) {\n  const pill = useContext(PillContext);\n  const element = useRender({\n    defaultTagName: \"a\",\n    render,\n    props: mergeProps<\"a\">(\n      {\n        \"aria-current\": active ? \"page\" : undefined,\n        onPointerEnter: (e: React.PointerEvent<HTMLAnchorElement>) => {\n          onPointerEnter?.(e);\n          if (e.pointerType !== \"touch\") pill?.enter(e.currentTarget);\n        },\n        className: cn(\n          \"relative flex h-8 items-center whitespace-nowrap rounded-full px-3 text-[13px] text-fg-2 no-underline outline-none\",\n          \"transition-[color,scale] duration-150 hover:text-fg active:scale-[0.97] active:duration-75 aria-[current=page]:text-fg\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n          \"before:absolute before:inset-x-0 before:-inset-y-1.5 before:content-[''] pointer-fine:before:hidden\",\n          className,\n        ),\n      },\n      rest,\n    ),\n  });\n  // The li stays unpositioned so the link measures against the list, where the pill lives.\n  return <li>{element}</li>;\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/navbar"
}
