{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "handoff-nav",
  "type": "registry:ui",
  "title": "Handoff Nav",
  "description": "A navbar that tightens into a floating pill once the page scrolls, and catches your hero's call to action: as the hero button rides up behind the pill it flies into a slot at the end, and when you scroll back it flies home. On narrow screens the pill grows down into a menu.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "navigation",
    "scroll",
    "interaction"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/handoff-nav",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/handoff-nav.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - The caught button is a second copy in the pill's slot, flown by `transform`, width and height\n *   on one spring's progress; the hero keeps an empty space its size, so nothing under it moves.\n * - A flight runs in two legs so the button never crosses the links: up, it slides along under the\n *   pill and rises into the slot last; home, it drops out of the pill first and then slides.\n * - The label only travels, it never scales: it sits in one span that keeps its own width, which is\n *   also what the pill measures to open the slot.\n * - The hand between copies is invisible: the copy is placed over its start before the original\n *   hides, coming home both show for a frame or two (never neither), and focus moves with it.\n * - Reduced motion: nothing travels. The bar swaps to the pill at once, the button crossfades\n *   between hero and pill, and the menu opens without growing.\n */\nconst TIMING = {\n  /** The bar tightening into the pill: a spring, in seconds (`visualDuration`: `dockDuration`). */\n  dock: { type: \"spring\", visualDuration: 0.4, bounce: 0 },\n  /** Spreading out again at the top, quicker: a spring, in seconds, scaled by `dockDuration`. */\n  undock: { type: \"spring\", visualDuration: 0.3, bounce: 0 },\n  /** The slot opening, ahead of the button: a spring, in seconds. */\n  make: { type: \"spring\", visualDuration: 0.3, bounce: 0 },\n  /** The slot closing, behind it: a spring, in seconds. */\n  shut: { type: \"spring\", visualDuration: 0.34, bounce: 0, delay: 0.06 },\n  /**\n   * The button flying up into the pill: a spring, in seconds (`visualDuration` is the\n   * `catchDuration` prop, `bounce` the `catchBounce` prop).\n   */\n  catch: { type: \"spring\", visualDuration: 0.45, bounce: 0.08 },\n  /** And back down into the hero: a spring, in seconds, scaled by `catchDuration`. */\n  release: { type: \"spring\", visualDuration: 0.38, bounce: 0 },\n  /** The menu panel growing: a spring, in seconds. */\n  open: { type: \"spring\", visualDuration: 0.4, bounce: 0.06 },\n  /** The menu panel closing: a spring, in seconds. */\n  close: { type: \"spring\", visualDuration: 0.3, bounce: 0 },\n  /** The link highlight sliding: a spring, in seconds. */\n  hover: { type: \"spring\", visualDuration: 0.3, bounce: 0 },\n  /** The pill's surface fading in or out, in seconds. */\n  fill: { duration: 0.2, ease: [0.22, 1, 0.36, 1] },\n  /** Fades: the link highlight, reduced motion's swaps, a far-off button arriving, in seconds. */\n  fade: { duration: 0.15, ease: \"easeOut\" },\n  /** A far-off button waiting for its slot to open before it fades in, in seconds. */\n  slotWait: 0.2,\n  /** Between menu links as they arrive, in seconds. */\n  stagger: 0.03,\n  /** Between menu links as they leave, last first, in seconds. */\n  tuck: 0.02,\n  /** The links' and buttons' hover color, as a Tailwind class. */\n  color: \"duration-150\",\n  /** The hero button's hover fill and press, as Tailwind classes. */\n  press: \"duration-150 ease-out\",\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * HANDOFF NAV STORYBOARD\n *\n *  rest     a full-width bar on the page itself: logo at the start,\n *           links and a quiet outlined action at the end, 64px tall,\n *           no fill and no border\n *  dock     past 24px of scroll the bar tightens into a pill that\n *           floats 12px below the top. It narrows to fit what's in it\n *           and drops to 48px while the frosted fill, the hairline and\n *           a soft shadow fade in: one 0.4s spring, no bounce. Back at\n *           the top it spreads out again, quicker (0.3s)\n *  catch    the hero's button rides up the page, and the moment its\n *           top edge meets the pill's underside it lifts off. The pill\n *           widens to open a slot at its end (0.3s, the links slide aside)\n *           and the button flies into it, going from 44px to 32px tall\n *           on a 0.45s spring with the smallest settle. Its label only\n *           travels, it never scales. It slides along under the pill\n *           and rises into the slot last, so it never crosses the\n *           links. The hero keeps an empty space the button's size, so\n *           nothing under it moves\n *  release  scroll back until that space is 8px clear of the pill and\n *           the button drops out of the pill and flies home (0.38s, no\n *           bounce), following the space as the page moves; the slot\n *           closes behind it\n *  again    reversing mid-flight turns the button round from wherever\n *           it is. A button that's far off screen (a jump to the\n *           bottom) fades into the slot instead of crossing the page\n *  hover    a soft highlight slides from link to link under a mouse\n *  menu     on narrow screens the links fold into a menu button. The\n *           pill keeps its width, so the button stays under the finger,\n *           and grows down into a panel (0.4s, a slight settle); its\n *           links rise 8px into place 30ms apart and tuck back in,\n *           last first, when it closes (0.3s)\n *  keys     Tab runs logo → links → action → caught button → menu. Esc\n *           closes the menu and puts focus back on its button. A\n *           focused hero button hands its focus to the caught copy\n *  reduced  nothing travels: the bar swaps to the pill at once, the\n *           button fades out of the hero while its copy fades into the\n *           pill, and the menu opens without growing\n * ───────────────────────────────────────────────────────── */\n\nconst NAV = {\n  rest: 64, // px; the bar's height at the top of the page\n  docked: 48, // px; the pill's height\n  top: 12, // px; the pill's distance from the top once docked\n  padStart: 16, // px inside the pill before the logo\n  padEnd: 8, // px inside the pill after the last item\n  gap: 32, // px; the least room between the logo and the links in the pill\n  slotGap: 8, // px between the links and the caught button\n  clear: 8, // px the hero's space must clear below the pill before the button flies home\n  rise: 8, // px a menu link rises into place from\n} as const;\n\ntype Why = \"dock\" | \"undock\" | \"catch\" | \"release\" | \"open\" | \"close\";\ntype Mode = { docked: boolean; caught: boolean; open: boolean; why: Why };\ntype Rect = { x: number; y: number; w: number; h: number };\ntype Sizes = { rest: number; logo: number; group: number; menu: number; cta: number };\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n// A flight in two legs, so the button never crosses the links. Going up it slides along under the\n// pill first and rises into its slot last; going home it drops out of the pill first and then\n// slides to its place. Each leg eases out over its share of the spring's progress, and past 1\n// (the spring's settle) both run straight, so the overshoot stays small.\nconst leg = (t: number, start: number, end: number) => {\n  if (t >= 1) return t;\n  const u = Math.min(1, Math.max(0, (t - start) / (end - start)));\n  return 1 - (1 - u) * (1 - u);\n};\nconst flightRect = (a: Rect, b: Rect, t: number, to: \"nav\" | \"home\"): Rect => ({\n  x: lerp(a.x, b.x, to === \"nav\" ? leg(t, 0, 0.7) : leg(t, 0.3, 1)),\n  y: lerp(a.y, b.y, to === \"nav\" ? leg(t, 0.55, 1) : leg(t, 0, 0.45)),\n  w: lerp(a.w, b.w, t),\n  h: lerp(a.h, b.h, t),\n});\nconst toRect = (r: DOMRect): Rect => ({ x: r.left, y: r.top, w: r.width, h: r.height });\n\n/* ── Handoff store ──────────────────────────────────────── */\n\n// What the hero's button tells the nav: how to draw its copy, and where it lives. One store per\n// provider, so two navs on one page (or one docs page) never catch each other's buttons.\ntype Cta = {\n  label: React.ReactNode;\n  href?: string;\n  onClick?: React.MouseEventHandler<HTMLElement>;\n  className?: string;\n};\ntype HandoffState = { cta: Cta | null; away: boolean };\n\nconst EMPTY: HandoffState = { cta: null, away: false };\n\nfunction createHandoff() {\n  let state = EMPTY;\n  let home: HTMLElement | null = null;\n  const listeners = new Set<() => void>();\n  return {\n    get: () => state,\n    set(patch: Partial<HandoffState>) {\n      state = { ...state, ...patch };\n      listeners.forEach((listener) => listener());\n    },\n    subscribe(listener: () => void) {\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n      };\n    },\n    home: () => home,\n    setHome(element: HTMLElement | null) {\n      home = element;\n    },\n  };\n}\n\ntype Handoff = ReturnType<typeof createHandoff>;\n\nconst HandoffContext = React.createContext<Handoff | null>(null);\nconst noSubscribe = () => () => {};\n\nfunction useHandoffState(store: Handoff | null) {\n  return React.useSyncExternalStore(\n    store?.subscribe ?? noSubscribe,\n    () => store?.get() ?? EMPTY,\n    () => EMPTY,\n  );\n}\n\n/* ── Types ──────────────────────────────────────────────── */\n\nexport interface HandoffNavLink {\n  label: string;\n  href: string;\n}\n\nexport interface HandoffNavProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** Your logo or wordmark, at the start of the bar. Wrap it in a link to your home page. */\n  logo: React.ReactNode;\n  /** The page links. They sit at the end of the bar and fold into a menu on narrow screens. */\n  links?: HandoffNavLink[];\n  /** One quieter action after the links, like \"Log in\", drawn as an outlined pill. */\n  action?: HandoffNavLink;\n  /** Pixels of scroll before the bar docks into the pill. */\n  dockAt?: number;\n  /** Seconds the bar takes to tighten into the pill. Spreading out again at the top stays a little quicker. */\n  dockDuration?: number;\n  /** Seconds the hero's button takes to fly into the pill. The flight home scales with it. */\n  catchDuration?: number;\n  /** How much the caught button overshoots its slot and settles, from 0 (none) up. */\n  catchBounce?: number;\n  /** The element that scrolls, when it isn't the window. The nav then sticks to the top of that box: put it first inside it. */\n  containerRef?: React.RefObject<HTMLElement | null>;\n  /** Classes for the docked pill's surface: its fill, border and shadow. */\n  surfaceClassName?: string;\n}\n\nexport interface HandoffNavCtaProps {\n  /** The button's label. The same content is drawn in the nav once it's caught. */\n  children: React.ReactNode;\n  /** Where the button goes. Without it, it's a `<button>`. */\n  href?: string;\n  /** Called when either copy is clicked, in the hero or in the nav. */\n  onClick?: React.MouseEventHandler<HTMLElement>;\n  /** Classes for both copies. Target one with `data-[placement=hero]:` or `data-[placement=nav]:`. */\n  className?: string;\n}\n\n/* ── CtaButton ──────────────────────────────────────────── */\n\n// The button itself, drawn in the hero and again in the nav. Its contents sit in one span, so the\n// label keeps its size and only the button around it changes during a flight.\nfunction CtaButton({\n  cta,\n  placement,\n  className,\n  ref,\n  labelRef,\n}: {\n  cta: Cta;\n  placement: \"hero\" | \"nav\";\n  className?: string;\n  ref?: React.Ref<HTMLElement>;\n  labelRef?: React.Ref<HTMLSpanElement>;\n}) {\n  const classes = cn(\n    \"inline-flex shrink-0 items-center justify-center rounded-full text-sm font-medium whitespace-nowrap\",\n    `bg-zinc-950 text-white transition-[background-color,scale] ${TIMING.press} hover:bg-zinc-800 active:scale-[0.97]`,\n    \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950\",\n    \"dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200 dark:focus-visible:outline-white\",\n    placement === \"hero\" ? \"h-11 px-5\" : \"h-8 px-3.5\",\n    cta.className,\n    className,\n  );\n  const label = (\n    <span ref={labelRef} className=\"inline-flex items-center gap-1.5\">\n      {cta.label}\n    </span>\n  );\n  if (cta.href) {\n    return (\n      <a\n        ref={ref as React.Ref<HTMLAnchorElement>}\n        href={cta.href}\n        onClick={cta.onClick}\n        data-placement={placement}\n        className={classes}\n      >\n        {label}\n      </a>\n    );\n  }\n  return (\n    <button\n      ref={ref as React.Ref<HTMLButtonElement>}\n      type=\"button\"\n      onClick={cta.onClick}\n      data-placement={placement}\n      className={classes}\n    >\n      {label}\n    </button>\n  );\n}\n\n/* ── HandoffNavProvider ─────────────────────────────────── */\n\n/** Wraps the nav and the hero, so the hero's button can find the nav that catches it. */\nexport function HandoffNavProvider({ children }: { children: React.ReactNode }) {\n  const [store] = React.useState(createHandoff);\n  return <HandoffContext.Provider value={store}>{children}</HandoffContext.Provider>;\n}\n\n/* ── HandoffNavCta ──────────────────────────────────────── */\n\n/**\n * The hero's call to action. It renders the button where you put it, and when it rides up behind\n * the docked nav, the nav catches it. Its place in the hero stays reserved while it's away.\n */\nexport function HandoffNavCta({ children, href, onClick, className }: HandoffNavCtaProps) {\n  const store = React.useContext(HandoffContext);\n  const { away } = useHandoffState(store);\n  const reduced = useReducedMotion() ?? false;\n  const homeRef = React.useRef<HTMLSpanElement>(null);\n\n  React.useEffect(() => {\n    if (!store) return;\n    store.setHome(homeRef.current);\n    return () => {\n      store.setHome(null);\n      store.set({ cta: null, away: false });\n    };\n  }, [store]);\n\n  React.useEffect(() => {\n    store?.set({ cta: { label: children, href, onClick, className } });\n  }, [store, children, href, onClick, className]);\n\n  return (\n    <span ref={homeRef} className=\"inline-flex\">\n      {/* Out of view while the nav holds the button; the span keeps its place in the hero. */}\n      <motion.span\n        className=\"inline-flex\"\n        initial={false}\n        animate={away ? { opacity: 0, transitionEnd: { visibility: \"hidden\" } } : { opacity: 1, visibility: \"visible\" }}\n        transition={reduced ? TIMING.fade : { duration: 0 }}\n      >\n        <CtaButton cta={{ label: children, href, onClick, className }} placement=\"hero\" />\n      </motion.span>\n    </span>\n  );\n}\n\n/* ── HandoffNav ─────────────────────────────────────────── */\n\n/**\n * A navbar that docks into a floating pill once the page scrolls, and catches the hero's\n * `HandoffNavCta` as it slides up behind it.\n */\nexport function HandoffNav({\n  logo,\n  links = [],\n  action,\n  dockAt = 24,\n  dockDuration = TIMING.dock.visualDuration,\n  catchDuration = TIMING.catch.visualDuration,\n  catchBounce = TIMING.catch.bounce,\n  containerRef,\n  surfaceClassName,\n  className,\n  ...props\n}: HandoffNavProps) {\n  const store = React.useContext(HandoffContext);\n  const { cta } = useHandoffState(store);\n  const hasCta = cta !== null;\n  const reduced = useReducedMotion() ?? false;\n  const panelId = React.useId();\n\n  const [mode, setMode] = React.useState<Mode>({ docked: false, caught: false, open: false, why: \"dock\" });\n  const [sizes, setSizes] = React.useState<Sizes | null>(null);\n  const [hover, setHover] = React.useState({ x: 0, w: 0, on: false, jump: true });\n\n  const modeRef = React.useRef(mode);\n  const sizesRef = React.useRef<Sizes | null>(null);\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const pillRef = React.useRef<HTMLDivElement>(null);\n  const logoRef = React.useRef<HTMLDivElement>(null);\n  const groupRef = React.useRef<HTMLDivElement>(null);\n  const menuRef = React.useRef<HTMLSpanElement>(null);\n  const menuButtonRef = React.useRef<HTMLButtonElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const slotRef = React.useRef<HTMLDivElement>(null);\n  const flyerRef = React.useRef<HTMLElement>(null);\n  const labelRef = React.useRef<HTMLSpanElement>(null);\n  const flight = React.useRef<{ controls: AnimationPlaybackControls | null; rect: Rect | null }>({\n    controls: null,\n    rect: null,\n  });\n  const reducedRef = React.useRef(reduced);\n\n  React.useEffect(() => {\n    reducedRef.current = reduced;\n  }, [reduced]);\n\n  const commit = React.useCallback((next: Mode) => {\n    modeRef.current = next;\n    setMode(next);\n  }, []);\n\n  const setOpen = React.useCallback(\n    (open: boolean) => {\n      if (modeRef.current.open === open) return;\n      commit({ ...modeRef.current, open, why: open ? \"open\" : \"close\" });\n    },\n    [commit],\n  );\n\n  // Measure what the pill has to hold. The links and the action keep their own widths whatever\n  // the pill is doing, so the docked width is a sum and never a guess.\n  React.useEffect(() => {\n    const track = trackRef.current;\n    if (!track) return;\n    const measure = () => {\n      const flyer = flyerRef.current;\n      const label = labelRef.current;\n      // The label keeps its own width during a flight, so this holds even mid-air.\n      let cta = 0;\n      if (flyer && label) {\n        const style = getComputedStyle(flyer);\n        cta = label.offsetWidth + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);\n      }\n      const next: Sizes = {\n        rest: track.clientWidth,\n        logo: logoRef.current?.offsetWidth ?? 0,\n        group: groupRef.current?.offsetWidth ?? 0,\n        menu: menuRef.current?.offsetWidth ?? 0,\n        cta,\n      };\n      // The menu button is gone once the links fit: a menu left open would have nothing to hold.\n      if (!next.menu && modeRef.current.open) setOpen(false);\n      const prev = sizesRef.current;\n      if (\n        prev &&\n        prev.rest === next.rest &&\n        prev.logo === next.logo &&\n        prev.group === next.group &&\n        prev.menu === next.menu &&\n        prev.cta === next.cta\n      )\n        return;\n      sizesRef.current = next;\n      setSizes(next);\n    };\n    // The observer reports each element once on observe, which is the first measure.\n    let live = true;\n    const observer = new ResizeObserver(measure);\n    for (const element of [track, logoRef.current, groupRef.current, menuRef.current, labelRef.current]) {\n      if (element) observer.observe(element);\n    }\n    document.fonts?.ready.then(() => {\n      if (live) measure();\n    });\n    return () => {\n      live = false;\n      observer.disconnect();\n    };\n  }, [hasCta, setOpen]);\n\n  // Watch the scroll: dock past `dockAt`, catch the hero's button when it meets the pill's\n  // underside, and send it home once its space is clear again.\n  React.useEffect(() => {\n    const scroller = containerRef?.current ?? null;\n    const source: HTMLElement | Window = scroller ?? window;\n\n    const fly = (to: \"nav\" | \"home\") => {\n      const flyer = flyerRef.current;\n      const slot = slotRef.current;\n      const home = store?.home();\n      if (!flyer || !slot || !home || !store) return;\n      const sizes = sizesRef.current;\n      const navWidth = sizes?.cta || flyer.offsetWidth;\n      const navHeight = parseFloat(getComputedStyle(flyer).minHeight) || 32;\n      const hadFocus = (to === \"nav\" ? home : flyer).contains(document.activeElement);\n      const homeRect = () => toRect(home.getBoundingClientRect());\n      // The slot's live rect: the pill may still be docking or widening while the button flies.\n      const navRect = (): Rect => {\n        const s = slot.getBoundingClientRect();\n        return { x: s.right - navWidth, y: s.top + (s.height - navHeight) / 2, w: navWidth, h: navHeight };\n      };\n      const place = (r: Rect) => {\n        flight.current.rect = r;\n        const s = slot.getBoundingClientRect();\n        flyer.style.transform = `translate(${r.x - (s.right - r.w)}px, ${r.y - s.top}px)`;\n        flyer.style.width = `${r.w}px`;\n        flyer.style.height = `${r.h}px`;\n      };\n      const settle = () => {\n        flyer.style.transform = \"\";\n        flyer.style.width = \"\";\n        flyer.style.height = \"\";\n      };\n\n      flight.current.controls?.stop();\n      flight.current.controls = null;\n\n      const viewTop = scroller ? scroller.getBoundingClientRect().top : 0;\n      const viewBottom = scroller ? scroller.getBoundingClientRect().bottom : window.innerHeight;\n      const from = flight.current.rect ?? (to === \"nav\" ? homeRect() : navRect());\n      const offscreen = from.y + from.h < viewTop || from.y > viewBottom;\n\n      if (reducedRef.current || (offscreen && !flight.current.rect)) {\n        // Nothing crosses the page: the copy fades in (or out) in its slot.\n        flight.current.rect = null;\n        settle();\n        if (to === \"nav\") {\n          flyer.style.opacity = \"0\";\n          flyer.style.visibility = \"visible\";\n          store.set({ away: true });\n          // Without reduced motion the slot is still opening: wait until it has room.\n          const fading = animate(flyer, { opacity: [0, 1] }, { ...TIMING.fade, delay: reducedRef.current ? 0 : TIMING.slotWait });\n          flight.current.controls = fading;\n          fading.then(() => {\n            if (flight.current.controls === fading) flight.current.controls = null;\n          });\n          if (hadFocus) flyer.focus({ preventScroll: true });\n        } else {\n          store.set({ away: false });\n          const fading = animate(flyer, { opacity: [1, 0] }, TIMING.fade);\n          flight.current.controls = fading;\n          fading.then(() => {\n            if (flight.current.controls !== fading) return;\n            flight.current.controls = null;\n            if (!modeRef.current.caught) flyer.style.visibility = \"\";\n          });\n          if (hadFocus) home.querySelector<HTMLElement>(\"a,button\")?.focus({ preventScroll: true });\n        }\n        return;\n      }\n\n      // Put the copy exactly over its starting point first, then hide the original, so the hand\n      // between the two is invisible.\n      flyer.style.opacity = \"1\";\n      flyer.style.visibility = \"visible\";\n      place(from);\n      if (to === \"nav\") {\n        store.set({ away: true });\n        if (hadFocus) flyer.focus({ preventScroll: true });\n      }\n      const target = to === \"nav\" ? navRect : homeRect;\n      const controls = animate(0, 1, {\n        ...(to === \"nav\"\n          ? { ...TIMING.catch, visualDuration: catchDuration, bounce: catchBounce }\n          : { ...TIMING.release, visualDuration: TIMING.release.visualDuration * (catchDuration / TIMING.catch.visualDuration) }),\n        onUpdate: (t) => place(flightRect(from, target(), t, to)),\n        onComplete: () => {\n          if (flight.current.controls !== controls) return;\n          flight.current.controls = null;\n          flight.current.rect = null;\n          if (to === \"nav\") {\n            settle();\n            return;\n          }\n          store.set({ away: false });\n          // A frame or two with both drawn in the same spot, never a frame with neither.\n          requestAnimationFrame(() =>\n            requestAnimationFrame(() => {\n              if (modeRef.current.caught || flight.current.controls) return;\n              settle();\n              flyer.style.visibility = \"\";\n              if (hadFocus) home.querySelector<HTMLElement>(\"a,button\")?.focus({ preventScroll: true });\n            }),\n          );\n        },\n      });\n      flight.current.controls = controls;\n    };\n\n    let frame = 0;\n    const check = () => {\n      frame = 0;\n      const top = scroller ? scroller.scrollTop : window.scrollY;\n      const docked = top > dockAt;\n      const prev = modeRef.current;\n      let caught = prev.caught;\n      const home = store?.home();\n      if (!store || !store.get().cta || !home) {\n        caught = false;\n      } else {\n        const r = home.getBoundingClientRect();\n        const line = (scroller ? scroller.getBoundingClientRect().top : 0) + NAV.top + NAV.docked;\n        if (!docked || (r.width === 0 && r.height === 0)) caught = false;\n        else if (!caught && r.top < line) caught = true;\n        else if (caught && r.top > line + NAV.clear) caught = false;\n      }\n      if (docked === prev.docked && caught === prev.caught) return;\n      const why: Why = caught !== prev.caught ? (caught ? \"catch\" : \"release\") : docked ? \"dock\" : \"undock\";\n      commit({ ...prev, docked, caught, why });\n      if (caught !== prev.caught) fly(caught ? \"nav\" : \"home\");\n    };\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(check);\n    };\n\n    source.addEventListener(\"scroll\", onScroll, { passive: true });\n    window.addEventListener(\"resize\", onScroll);\n    // A page that loads already scrolled (a restored position) docks and catches straight away.\n    onScroll();\n    return () => {\n      source.removeEventListener(\"scroll\", onScroll);\n      window.removeEventListener(\"resize\", onScroll);\n      cancelAnimationFrame(frame);\n    };\n  }, [containerRef, dockAt, store, hasCta, commit, catchDuration, catchBounce]);\n\n  React.useEffect(() => () => flight.current.controls?.stop(), []);\n\n  // The open menu: Esc and a press outside close it, and focus moves into the panel.\n  React.useEffect(() => {\n    if (!mode.open) return;\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      setOpen(false);\n      menuButtonRef.current?.focus();\n    };\n    const onDown = (event: PointerEvent) => {\n      if (!pillRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"pointerdown\", onDown);\n    const frame = requestAnimationFrame(() =>\n      panelRef.current?.querySelector<HTMLElement>(\"a\")?.focus({ preventScroll: true }),\n    );\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"pointerdown\", onDown);\n      cancelAnimationFrame(frame);\n    };\n  }, [mode.open, setOpen]);\n\n  const surface = mode.docked || mode.open;\n  const caught = mode.caught && hasCta;\n  const slotWidth = caught && sizes ? NAV.slotGap + sizes.cta : 0;\n  const dockedWidth = sizes\n    ? Math.min(\n        sizes.rest,\n        NAV.padStart + sizes.logo + NAV.gap + sizes.group + slotWidth + sizes.menu + NAV.padEnd,\n      )\n    : 0;\n  // An open menu keeps the pill's width, so the close button stays under the finger that opened it.\n  const width = !sizes ? \"100%\" : mode.docked ? dockedWidth : sizes.rest;\n  // The slot opens ahead of a caught button and closes just behind a released one.\n  const move = reduced\n    ? { duration: 0 }\n    : mode.why === \"catch\"\n      ? TIMING.make\n      : mode.why === \"release\"\n        ? TIMING.shut\n        : mode.why === \"dock\"\n          ? { ...TIMING.dock, visualDuration: dockDuration }\n          : mode.why === \"undock\"\n            ? { ...TIMING.undock, visualDuration: TIMING.undock.visualDuration * (dockDuration / TIMING.dock.visualDuration) }\n            : TIMING[mode.why];\n  const panelMove = reduced ? { duration: 0 } : mode.open ? TIMING.open : TIMING.close;\n  // A menu row: it rises into place, or with reduced motion only fades (its rise jumps).\n  const rowMove = (delay: number) =>\n    reduced\n      ? { default: { duration: 0 }, opacity: { ...TIMING.fade, delay } }\n      : { ...(mode.open ? TIMING.open : TIMING.close), delay };\n\n  return (\n    <header\n      {...props}\n      className={cn(\n        \"pointer-events-none z-50 block h-16\",\n        containerRef ? \"sticky top-0\" : \"relative\",\n        className,\n      )}\n    >\n      <div className={cn(\"@container/handoff-nav inset-x-0 top-0\", containerRef ? \"absolute\" : \"fixed z-50\")}>\n        <div className=\"mx-auto w-full max-w-[var(--handoff-nav-width,72rem)] px-4 @xl/handoff-nav:px-6\">\n          <div ref={trackRef} className=\"w-full\">\n            <motion.div\n              ref={pillRef}\n              className=\"pointer-events-auto relative mx-auto\"\n              initial={false}\n              animate={{ width, y: surface ? NAV.top : 0 }}\n              transition={move}\n            >\n              <motion.div\n                aria-hidden\n                className={cn(\n                  \"absolute inset-0 rounded-[24px] border border-zinc-950/[0.08] bg-white/80 shadow-[0_10px_30px_-14px_rgb(9_9_11/0.3)] backdrop-blur-xl\",\n                  \"dark:border-white/10 dark:bg-zinc-900/80 dark:shadow-[0_10px_30px_-12px_rgb(0_0_0/0.7)]\",\n                  surfaceClassName,\n                )}\n                initial={false}\n                animate={{ opacity: surface ? 1 : 0 }}\n                transition={TIMING.fill}\n              />\n\n              <motion.nav\n                aria-label=\"Main\"\n                className=\"relative flex items-center\"\n                initial={false}\n                animate={{\n                  height: surface ? NAV.docked : NAV.rest,\n                  paddingLeft: surface ? NAV.padStart : 0,\n                  paddingRight: surface ? NAV.padEnd : 0,\n                }}\n                transition={move}\n              >\n                <div ref={logoRef} className=\"flex shrink-0 items-center\">\n                  {logo}\n                </div>\n\n                <div className=\"ml-auto flex shrink-0 items-center\">\n                  <div\n                    ref={groupRef}\n                    className=\"hidden shrink-0 items-center gap-1 @2xl/handoff-nav:flex\"\n                    onPointerLeave={() => setHover((h) => ({ ...h, on: false }))}\n                  >\n                    {links.length > 0 && (\n                      <div className=\"relative flex items-center\">\n                        <motion.span\n                          aria-hidden\n                          className=\"pointer-events-none absolute top-0 left-0 h-8 rounded-full bg-zinc-950/[0.05] dark:bg-white/[0.08]\"\n                          initial={false}\n                          animate={{ x: hover.x, width: hover.w, opacity: hover.on ? 1 : 0 }}\n                          transition={\n                            hover.jump || reduced\n                              ? { x: { duration: 0 }, width: { duration: 0 }, opacity: TIMING.fade }\n                              : { x: TIMING.hover, width: TIMING.hover, opacity: TIMING.fade }\n                          }\n                        />\n                        {links.map((link) => (\n                          <a\n                            key={link.href + link.label}\n                            href={link.href}\n                            onPointerEnter={(event) => {\n                              if (event.pointerType !== \"mouse\") return;\n                              const el = event.currentTarget;\n                              setHover((h) => ({ x: el.offsetLeft, w: el.offsetWidth, on: true, jump: !h.on }));\n                            }}\n                            className={`relative flex h-8 items-center rounded-full px-3 text-sm text-zinc-600 transition-colors ${TIMING.color} hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:text-zinc-400 dark:hover:text-white dark:focus-visible:outline-white`}\n                          >\n                            {link.label}\n                          </a>\n                        ))}\n                      </div>\n                    )}\n                    {action && (\n                      <a\n                        href={action.href}\n                        className={`ml-1 flex h-8 items-center rounded-full border border-zinc-950/10 px-3.5 text-sm font-medium text-zinc-900 transition-colors ${TIMING.color} hover:bg-zinc-950/[0.04] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:border-white/15 dark:text-zinc-100 dark:hover:bg-white/[0.06] dark:focus-visible:outline-white`}\n                      >\n                        {action.label}\n                      </a>\n                    )}\n                  </div>\n\n                  {cta && (\n                    <motion.div\n                      ref={slotRef}\n                      className=\"relative h-8 shrink-0\"\n                      initial={false}\n                      animate={{ width: slotWidth }}\n                      transition={move}\n                    >\n                      <CtaButton\n                        ref={flyerRef}\n                        labelRef={labelRef}\n                        cta={cta}\n                        placement=\"nav\"\n                        className=\"invisible absolute top-0 right-0 min-h-8\"\n                      />\n                    </motion.div>\n                  )}\n\n                  <span\n                    ref={menuRef}\n                    className={cn(\"flex shrink-0 pl-1 @2xl/handoff-nav:hidden\", !links.length && !action && \"hidden\")}\n                  >\n                    <button\n                      ref={menuButtonRef}\n                      type=\"button\"\n                      aria-expanded={mode.open}\n                      aria-controls={panelId}\n                      aria-label={mode.open ? \"Close menu\" : \"Open menu\"}\n                      onClick={() => setOpen(!modeRef.current.open)}\n                      className={`relative grid size-10 place-items-center rounded-full text-zinc-900 transition-colors ${TIMING.color} hover:bg-zinc-950/[0.05] focus-visible:outline-2 focus-visible:outline-zinc-950 dark:text-zinc-100 dark:hover:bg-white/[0.08] dark:focus-visible:outline-white`}\n                    >\n                      {[-1, 1].map((side) => (\n                        <motion.span\n                          key={side}\n                          aria-hidden\n                          className=\"absolute h-[1.5px] w-4 rounded-full bg-current\"\n                          initial={false}\n                          animate={mode.open ? { y: 0, rotate: side * 45 } : { y: side * 3.5, rotate: 0 }}\n                          transition={reduced ? { duration: 0 } : panelMove}\n                        />\n                      ))}\n                    </button>\n                  </span>\n                </div>\n              </motion.nav>\n\n              <motion.div\n                id={panelId}\n                ref={panelRef}\n                className=\"relative overflow-hidden\"\n                initial={false}\n                animate={{ height: mode.open ? \"auto\" : 0 }}\n                transition={panelMove}\n                inert={!mode.open}\n              >\n                <ul className=\"flex flex-col px-2 pt-1 pb-2\">\n                  {links.map((link, index) => (\n                    <motion.li\n                      key={link.href + link.label}\n                      initial={false}\n                      animate={mode.open ? { opacity: 1, y: 0 } : { opacity: 0, y: -NAV.rise }}\n                      transition={rowMove(mode.open ? TIMING.stagger * (index + 1) : TIMING.tuck * (links.length - 1 - index))}\n                    >\n                      <a\n                        href={link.href}\n                        onClick={() => setOpen(false)}\n                        className={`flex h-11 items-center rounded-2xl px-3 text-[15px] text-zinc-800 transition-colors ${TIMING.color} hover:bg-zinc-950/[0.04] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-zinc-950 dark:text-zinc-200 dark:hover:bg-white/[0.06] dark:focus-visible:outline-white`}\n                      >\n                        {link.label}\n                      </a>\n                    </motion.li>\n                  ))}\n                  {action && (\n                    <motion.li\n                      className=\"mt-1 px-1\"\n                      initial={false}\n                      animate={mode.open ? { opacity: 1, y: 0 } : { opacity: 0, y: -NAV.rise }}\n                      transition={rowMove(mode.open ? TIMING.stagger * (links.length + 1) : 0)}\n                    >\n                      <a\n                        href={action.href}\n                        onClick={() => setOpen(false)}\n                        className={`flex h-11 items-center justify-center rounded-full border border-zinc-950/10 text-[15px] font-medium text-zinc-900 transition-colors ${TIMING.color} hover:bg-zinc-950/[0.04] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:border-white/15 dark:text-zinc-100 dark:hover:bg-white/[0.06] dark:focus-visible:outline-white`}\n                      >\n                        {action.label}\n                      </a>\n                    </motion.li>\n                  )}\n                </ul>\n              </motion.div>\n            </motion.div>\n          </div>\n        </div>\n      </div>\n    </header>\n  );\n}\n"
    }
  ]
}