{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"drift-nav","type":"registry:component","title":"Drift Nav","description":"A navigation bar that reads the ground under it and swaps its ink, frosts once the page scrolls, and carries a wind vane; its link underlines are grains drifting downwind.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/drift-nav.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport type { CSSProperties, RefObject } from \"react\";\n\n/**\n * DRIFT NAV.\n * A navigation bar that reads the ground under it and swaps its ink, frosts\n * once the page scrolls, and carries a wind vane; its link underlines are\n * grains drifting downwind.\n *\n * FONTS. Archivo with its width axis for display, Instrument Serif italic for\n * the accent word, Geist Mono for labels. In Next: Archivo({ subsets:\n * [\"latin\"], axes: [\"wdth\"], variable: \"--font-ae\" }), Instrument_Serif({\n * subsets: [\"latin\"], weight: \"400\", style: \"italic\", variable:\n * \"--font-ae-serif\" }) and Geist_Mono({ subsets: [\"latin\"], variable:\n * \"--font-ae-mono\" }) on <html>. Without them it falls back to system fonts.\n *\n * CLIENT. The file starts with \"use client\".\n *\n * WIND. Every Aeolian entry reads one page-wide wind (aeWind on globalThis),\n * so separately pasted entries still turn together.\n */\n\n/** The deepest ground: dusk, the footer. */\nconst AE_NIGHT = \"#150E0A\";\n\n/** Ink on the light grounds. */\nconst AE_UMBER = \"#2B1B12\";\n\nconst AE_DUNE = \"#B8743F\";\n\n/** Lit sand: marks on the night ground. */\nconst AE_SAND = \"#DDAA72\";\n\n/** The pale ground, and the colour a sandstorm turns everything. */\nconst AE_HAZE = \"#EFE2CD\";\n\n/** The one filled accent: the low sun. */\nconst AE_EMBER = \"#E0602C\";\n\n/**\n * The display face: Archivo, wide and light. Set its width with font-stretch\n * (112.5% or 125%), not font-variation-settings, so a canvas can paint the\n * same instance. In Next: Archivo({ subsets: [\"latin\"], axes: [\"wdth\"], variable: \"--font-ae\" }).\n */\nconst AE_DISPLAY = 'var(--font-ae, var(--font-display, \"Archivo\")), \"Archivo\", ui-sans-serif, system-ui, sans-serif';\n\n/** Labels and readouts. */\nconst AE_MONO = 'var(--font-ae-mono, var(--font-geist-mono, \"Geist Mono\")), \"Geist Mono\", ui-monospace, \"SFMono-Regular\", Menlo, monospace';\n\n/** Which ground a section sits on; the nav reads it to pick its ink. */\nexport type AeGround = \"night\" | \"haze\";\n\nconst aeReducedSnap = () => typeof window !== \"undefined\" && typeof window.matchMedia === \"function\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n/**\n * The scrolling box an element lives in: the nearest ancestor that actually\n * scrolls, or null for the window. Resolved on every frame, because a box only\n * becomes a scroller once its content is taller than it.\n */\nfunction aeScrollParent(el: Element | null): HTMLElement | null {\n  let n = el?.parentElement ?? null;\n  while (n && n !== document.body && n !== document.documentElement) {\n    const oy = getComputedStyle(n).overflowY;\n    if ((oy === \"auto\" || oy === \"scroll\" || oy === \"overlay\") && n.scrollHeight > n.clientHeight + 1) return n;\n    n = n.parentElement;\n  }\n  return null;\n}\n\ninterface AeFrame {\n  /** The element's box, in viewport pixels. */\n  rect: DOMRect;\n  /** The visible part of whatever scrolls it: the scroller's box, or the window. */\n  view: { top: number; height: number; left: number; width: number };\n}\n\n/**\n * Run `apply` with the element's position whenever anything scrolls or\n * resizes, at most once per frame. One capture-phase scroll listener hears\n * every scroller, so the same section works on a page and inside a scrolling\n * preview. `apply` writes to the DOM; nothing here re-renders React.\n */\nfunction useAeScrollFrame<T extends HTMLElement>(ref: RefObject<T | null>, apply: (f: AeFrame) => void) {\n  const applyRef = useRef(apply);\n  useEffect(() => {\n    applyRef.current = apply;\n  });\n  useEffect(() => {\n    let raf = 0;\n    const run = () => {\n      raf = 0;\n      const el = ref.current;\n      if (!el) return;\n      const sp = aeScrollParent(el);\n      const r = sp ? sp.getBoundingClientRect() : null;\n      const view = sp && r ? { top: r.top, height: sp.clientHeight, left: r.left, width: sp.clientWidth } : { top: 0, height: window.innerHeight, left: 0, width: window.innerWidth };\n      applyRef.current({ rect: el.getBoundingClientRect(), view });\n    };\n    const kick = () => {\n      if (!raf) raf = requestAnimationFrame(run);\n    };\n    document.addEventListener(\"scroll\", kick, { capture: true, passive: true });\n    window.addEventListener(\"resize\", kick);\n    const ro = typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(kick) : null;\n    if (ref.current && ro) ro.observe(ref.current);\n    run();\n    return () => {\n      document.removeEventListener(\"scroll\", kick, { capture: true });\n      window.removeEventListener(\"resize\", kick);\n      ro?.disconnect();\n      if (raf) cancelAnimationFrame(raf);\n    };\n  }, [ref]);\n}\n\n/** Links built from props: https, http, mailto, same-site paths and anchors only (tabs and newlines stripped, backslashes refused). */\nfunction aeSafeHref(href: string): string {\n  const h = href.replace(/[\\t\\n\\r]/g, \"\").trim();\n  if (!h || h.includes(String.fromCharCode(92))) return \"#\";\n  if (h.startsWith(\"#\") || (h.startsWith(\"/\") && !h.startsWith(\"//\"))) return h;\n  return /^(https?:|mailto:|tel:)/i.test(h) ? h : \"#\";\n}\n\n/** Scroll whatever scrolls the link to a same-page anchor ([data-ae-anchor]); anything else is left to the browser. */\nfunction aeGo(e: { currentTarget: HTMLAnchorElement; preventDefault: () => void }) {\n  const href = e.currentTarget.getAttribute(\"href\") ?? \"\";\n  if (!href.startsWith(\"#\") || href.length < 2) return;\n  const root = e.currentTarget.closest(\"[data-ae-site]\") ?? document;\n  const target = root.querySelector<HTMLElement>('[data-ae-anchor=\"' + CSS.escape(href.slice(1)) + '\"]');\n  if (!target) return;\n  e.preventDefault();\n  const sp = aeScrollParent(target);\n  const smooth: ScrollBehavior = aeReducedSnap() ? \"auto\" : \"smooth\";\n  if (sp) sp.scrollTo({ top: sp.scrollTop + target.getBoundingClientRect().top - sp.getBoundingClientRect().top, behavior: smooth });\n  else window.scrollTo({ top: window.scrollY + target.getBoundingClientRect().top, behavior: smooth });\n}\n\n/**\n * The kit's scoped CSS: one focus ring (2px, offset 3px, sand on night and\n * umber on haze), tabular figures, the press, and transitions off for\n * anything a frame loop writes.\n */\nconst AE_CSS =\n  \".ae-scope{--ae-ring:\" + AE_SAND + \"}\" +\n  \".ae-scope[data-ae-ground=haze],.ae-scope [data-ae-ground=haze]{--ae-ring:\" + AE_UMBER + \"}\" +\n  \".ae-scope :focus-visible{outline:2px solid var(--ae-ring)!important;outline-offset:3px!important}\" +\n  \".ae-scope .ae-num{font-variant-numeric:tabular-nums}\" +\n  \".ae-scope [data-ae-live]{transition:none!important}\" +\n  \".ae-scope .ae-press{transition:transform 180ms cubic-bezier(.2,.8,.2,1),background-color 180ms,color 180ms,border-color 180ms}\" +\n  \".ae-scope .ae-press:active{transform:scale(.97)}\" +\n  \".ae-scope .ae-arr{transition:transform 240ms cubic-bezier(.2,.8,.2,1)}.ae-scope a:hover .ae-arr,.ae-scope button:hover .ae-arr{transform:translateX(3px)}\" +\n  \"@media (prefers-reduced-motion:reduce){.ae-scope .ae-press,.ae-scope .ae-arr{transition:none}}\";\n\n/** The kit's arrow: slides on hover. */\nfunction AeArrow({ size = 14 }: { size?: number }) {\n  return (\n    <svg className=\"ae-arr\" width={size} height={size} viewBox=\"0 0 14 14\" aria-hidden>\n      <path d=\"M2 7h9.5M7.8 3.4L11.5 7l-3.7 3.6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\n/** Barchan's mark: a crescent dune seen from above, horns downwind. Takes the current colour. */\nfunction AeMark({ size = 22 }: { size?: number }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 24 24\" aria-hidden>\n      <path d=\"M3 5.5C9.5 5.2 14.6 7.6 17 12c-2.4 4.4-7.5 6.8-14 6.5 4.6-1.6 7-3.9 7-6.5S7.6 7.1 3 5.5Z\" fill=\"currentColor\" />\n      <path d=\"M17 12c1.6-.2 2.9-.9 4-2.1-.4 1.6-.4 2.6 0 4.2-1.1-1.2-2.4-1.9-4-2.1Z\" fill=\"currentColor\" opacity=\".55\" />\n    </svg>\n  );\n}\n\n/**\n * The page's wind. `angle` is the way it blows TOWARD, in degrees on the\n * ground: 0 = to the right (east), 90 = away from you (north), 180 = left,\n * 270 = toward you. `speed` is in km/h. The dune stage, the grains, the dust\n * of every dissolve, the nav's vane and the footer all read this one store,\n * so turning the rose in the hero turns every grain on the page. It lives on\n * globalThis, so entries pasted as separate files still share one wind.\n */\nconst aeWind: { angle: number; speed: number; subs: Set<() => void> } = ((globalThis as unknown as { __aeolianWind?: { angle: number; speed: number; subs: Set<() => void> } }).__aeolianWind ??= { angle: 55, speed: 30, subs: new Set() });\n\n/** Hear every wind change; returns an unsubscribe. Listeners write to the DOM, not to React state. */\nfunction aeOnWind(fn: () => void): () => void {\n  aeWind.subs.add(fn);\n  return () => {\n    aeWind.subs.delete(fn);\n  };\n}\n\n/**\n * The wind as a direction on a flat screen: x right, y down. Wind blowing\n * away from you reads as rising, toward you as falling, a little flattened\n * (the ground is seen at a low angle). Unit length.\n */\nfunction aeWindScreen(angle = aeWind.angle): [number, number] {\n  const a = (angle * Math.PI) / 180;\n  const x = Math.cos(a);\n  const y = -Math.sin(a) * 0.55;\n  const l = Math.hypot(x, y) || 1;\n  return [x / l, y / l];\n}\n\nconst AE_POINTS = [\"N\", \"NNE\", \"NE\", \"ENE\", \"E\", \"ESE\", \"SE\", \"SSE\", \"S\", \"SSW\", \"SW\", \"WSW\", \"W\", \"WNW\", \"NW\", \"NNW\"];\n\n/** Where the wind comes FROM, as a compass point (north is away from you): blowing to the right is a westerly. */\nfunction aeWindFrom(angle = aeWind.angle): string {\n  const toward = (((90 - angle) % 360) + 360) % 360;\n  const from = (toward + 180) % 360;\n  return AE_POINTS[Math.round(from / 22.5) % 16] ?? \"N\";\n}\n\n/** Ink for each ground: text, the CTA's fill and its label, and the glass the bar fills with once scrolled. */\nconst NV_INK: Record<AeGround, { ink: string; cta: string; ctaInk: string; glass: string }> = {\n  night: { ink: AE_HAZE, cta: AE_EMBER, ctaInk: \"#1A0E07\", glass: \"rgba(21,14,10,.62)\" },\n  haze: { ink: AE_UMBER, cta: AE_UMBER, ctaInk: AE_HAZE, glass: \"rgba(239,226,205,.72)\" },\n};\n\nconst NV_CSS =\n  \".ae-nv{transition:color 260ms,background-color 260ms,height 280ms cubic-bezier(.2,.8,.2,1),box-shadow 260ms}\" +\n  \".ae-nv a,.ae-nv button{transition:color 260ms,background-color 260ms}\" +\n  \".ae-nv-link{position:relative;text-decoration:none}\" +\n  // The underline is a row of grains drifting downwind (the bar sets --ae-drift to the wind's sign).\n  \".ae-nv-link::after{content:'';position:absolute;left:-2px;right:-2px;bottom:-7px;height:3px;opacity:0;\" +\n  \"background-image:radial-gradient(circle,currentColor 0 .9px,transparent 1.2px),radial-gradient(circle,currentColor 0 .6px,transparent .9px);\" +\n  \"background-size:7px 3px,5px 3px;background-position:0 0,2px 1px;\" +\n  \"-webkit-mask-image:linear-gradient(90deg,transparent,#000 22%,#000 78%,transparent);mask-image:linear-gradient(90deg,transparent,#000 22%,#000 78%,transparent);\" +\n  \"transition:opacity 220ms}\" +\n  \".ae-nv-link:hover::after,.ae-nv-link:focus-visible::after{opacity:.9;animation:ae-nv-drift 1.1s linear infinite}\" +\n  \"@keyframes ae-nv-drift{to{background-position:calc(var(--ae-drift,1) * 35px) 0,calc(var(--ae-drift,1) * 27px) 1px}}\" +\n  \".ae-nv-sheet{transition:clip-path 360ms cubic-bezier(.2,.8,.2,1)}\" +\n  \"@media (prefers-reduced-motion:reduce){.ae-nv,.ae-nv a,.ae-nv button,.ae-nv-sheet{transition:none}.ae-nv-link:hover::after,.ae-nv-link:focus-visible::after{animation:none}}\";\n\nexport interface DriftNavProps {\n  brand?: string;\n  /** \"sticky\" sticks to the top of its scroller; \"static\" leaves placement to you (the Barchan site floats it over the hero). */\n  position?: \"sticky\" | \"static\";\n  /** The ground to assume where no section under the bar carries data-ae-ground (the bar then wears its glass so its ink reads anywhere). Mark your sections data-ae-ground=\"night\" | \"haze\" to steer it. */\n  fallback?: AeGround;\n  links?: { label: string; href: string }[];\n  cta?: { label: string; href: string };\n  /** Show the live wind vane and readout. */\n  vane?: boolean;\n  className?: string;\n  style?: CSSProperties;\n}\n\n/**\n * DriftNav — a full-width bar that reads the ground under it (night or haze)\n * and swaps its ink, frosts once the page scrolls, and carries a wind vane\n * that turns with the page's wind. Link underlines are grains drifting\n * downwind.\n */\nexport function DriftNav({\n  brand = \"Barchan\",\n  links = [\n    { label: \"Camp\", href: \"#camp\" },\n    { label: \"Routes\", href: \"#routes\" },\n    { label: \"Stays\", href: \"#stays\" },\n    { label: \"Request\", href: \"#request\" },\n  ],\n  cta = { label: \"Reserve\", href: \"#stays\" },\n  position = \"sticky\",\n  fallback = \"night\",\n  vane = true,\n  className = \"\",\n  style,\n}: DriftNavProps) {\n  const ref = useRef<HTMLElement>(null);\n  const [ground, setGround] = useState<AeGround>(fallback);\n  const [compact, setCompact] = useState(false);\n  // No section under the bar says what it is (a page without data-ae-ground): wear the glass so the ink always reads.\n  const [bare, setBare] = useState(false);\n  const [open, setOpen] = useState(false);\n  const sheetId = useId();\n  const menuRef = useRef<HTMLButtonElement>(null);\n\n  const read = useCallback(() => {\n    const nav = ref.current;\n    if (!nav) return;\n    const r = nav.getBoundingClientRect();\n    let g: AeGround = fallback;\n    let found = false;\n    for (const el of document.elementsFromPoint(r.left + r.width * 0.5, r.top + r.height / 2)) {\n      if (nav.contains(el)) continue;\n      const v = el.closest<HTMLElement>(\"[data-ae-ground]\")?.dataset.aeGround;\n      if (v === \"night\" || v === \"haze\") {\n        g = v;\n        found = true;\n        break;\n      }\n    }\n    setGround(g);\n    setBare(!found);\n  }, [fallback]);\n\n  // Which ground is under the bar: whatever the page shows at the bar's centre line, not the bar itself.\n  useAeScrollFrame(ref, () => {\n    const nav = ref.current;\n    if (!nav) return;\n    read();\n    const sp = aeScrollParent(nav);\n    setCompact((sp ? sp.scrollTop : window.scrollY) > 80);\n  });\n\n  // A section can change its ground after the scroll has stopped (the hero's fade to sand-pale eases in), so listen for that too.\n  useEffect(() => {\n    const nav = ref.current;\n    if (!nav || typeof MutationObserver === \"undefined\") return;\n    let raf = 0;\n    const mo = new MutationObserver(() => {\n      if (!raf)\n        raf = requestAnimationFrame(() => {\n          raf = 0;\n          read();\n        });\n    });\n    mo.observe(nav.closest(\"[data-ae-site]\") ?? document.body, { subtree: true, attributes: true, attributeFilter: [\"data-ae-ground\"] });\n    return () => {\n      mo.disconnect();\n      if (raf) cancelAnimationFrame(raf);\n    };\n  }, [read]);\n\n  // The vane and the underline drift follow the wind, written to the DOM.\n  useEffect(() => {\n    const nav = ref.current;\n    if (!nav) return;\n    const draw = () => {\n      nav.style.setProperty(\"--ae-drift\", aeWindScreen()[0] < 0 ? \"-1\" : \"1\");\n      nav.querySelectorAll<SVGElement>(\"[data-nv-vane]\").forEach((v) => v.setAttribute(\"transform\", \"rotate(\" + (-aeWind.angle).toFixed(1) + \" 9 9)\"));\n      nav.querySelectorAll<HTMLElement>(\"[data-nv-wind]\").forEach((t) => {\n        t.textContent = aeWindFrom() + \" \" + Math.round(aeWind.speed);\n      });\n    };\n    draw();\n    return aeOnWind(draw);\n  }, [vane]);\n\n  useEffect(() => {\n    if (!open) return;\n    const key = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        setOpen(false);\n        menuRef.current?.focus();\n      }\n    };\n    document.addEventListener(\"keydown\", key);\n    // Widened past the phone layout with the sheet open: the sheet is gone, so is \"open\".\n    const nav = ref.current;\n    const ro = nav && typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(() => { if (nav.clientWidth >= 768) setOpen(false); }) : null;\n    if (nav) ro?.observe(nav);\n    return () => {\n      document.removeEventListener(\"keydown\", key);\n      ro?.disconnect();\n    };\n  }, [open]);\n\n  const c = NV_INK[ground];\n  const go = (e: { currentTarget: HTMLAnchorElement; preventDefault: () => void }) => {\n    // Leaving the sheet hands focus back to the menu button, not to the page body.\n    if (open) menuRef.current?.focus({ preventScroll: true });\n    setOpen(false);\n    aeGo(e);\n  };\n  return (\n    <nav\n      ref={ref}\n      aria-label=\"Main\"\n      data-ae-ground={ground}\n      className={\"ae-scope ae-nv @container w-full \" + (position === \"sticky\" ? \"sticky top-0 z-50 \" : \"relative \") + className}\n      style={{\n        color: c.ink,\n        background: compact || open || bare ? c.glass : \"transparent\",\n        backdropFilter: compact || open || bare ? \"blur(14px) saturate(1.2)\" : \"none\",\n        WebkitBackdropFilter: compact || open || bare ? \"blur(14px) saturate(1.2)\" : \"none\",\n        height: compact ? 56 : 72,\n        fontFamily: AE_DISPLAY,\n        boxShadow: compact ? \"inset 0 -1px 0 color-mix(in oklab, currentColor 14%, transparent)\" : \"none\",\n        ...style,\n      }}\n    >\n      <style>{AE_CSS + NV_CSS}</style>\n      <div className=\"flex h-full w-full items-center justify-between gap-6 px-5 @3xl:px-[3.2cqw]\">\n        <a href=\"#top\" onClick={go} className=\"flex items-center gap-2.5 no-underline\" style={{ color: \"inherit\" }}>\n          <span style={{ color: ground === \"night\" ? AE_SAND : AE_DUNE }}>\n            <AeMark size={22} />\n          </span>\n          <span className=\"text-[15px] uppercase leading-none tracking-[0.2em]\" style={{ fontWeight: 500, fontStretch: \"125%\" }}>\n            {brand}\n          </span>\n        </a>\n        <ul className=\"m-0 hidden list-none items-center gap-9 p-0 @3xl:flex\">\n          {links.map((l) => (\n            <li key={l.label}>\n              <a href={aeSafeHref(l.href)} onClick={go} className=\"ae-nv-link text-[14px]\" style={{ color: \"inherit\", fontStretch: \"100%\", fontWeight: 400 }}>\n                {l.label}\n              </a>\n            </li>\n          ))}\n        </ul>\n        <div className=\"flex items-center gap-3\">\n          {vane ? (\n            <span className=\"hidden items-center gap-1.5 text-[10.5px] uppercase tracking-[0.14em] @3xl:inline-flex\" style={{ fontFamily: AE_MONO, opacity: 0.8 }} title=\"The page's wind\">\n              <svg width=\"18\" height=\"18\" viewBox=\"0 0 18 18\" aria-hidden>\n                <circle cx=\"9\" cy=\"9\" r=\"8\" fill=\"none\" stroke=\"currentColor\" strokeOpacity=\".3\" />\n                <g data-nv-vane=\"\" transform={\"rotate(\" + (-aeWind.angle).toFixed(1) + \" 9 9)\"}>\n                  <path d=\"M3.5 9h9.5M10.6 6.4 13.4 9l-2.8 2.6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                </g>\n              </svg>\n              <span data-nv-wind=\"\" className=\"ae-num\">\n                {aeWindFrom() + \" \" + Math.round(aeWind.speed)}\n              </span>\n              <span className=\"sr-only\">km/h wind</span>\n            </span>\n          ) : null}\n          <a href={aeSafeHref(cta.href)} onClick={go} className=\"ae-press hidden h-10 items-center gap-2 rounded-full px-5 text-[13.5px] font-medium no-underline @md:inline-flex\" style={{ background: c.cta, color: c.ctaInk, fontStretch: \"100%\" }}>\n            {cta.label}\n            <AeArrow size={12} />\n          </a>\n          <button\n            ref={menuRef}\n            type=\"button\"\n            aria-expanded={open}\n            aria-controls={sheetId}\n            aria-label={open ? \"Close menu\" : \"Open menu\"}\n            onClick={() => setOpen((o) => !o)}\n            className=\"ae-press inline-flex h-10 w-10 items-center justify-center rounded-full border-0 @3xl:hidden\"\n            style={{ background: \"transparent\", color: \"inherit\", boxShadow: \"inset 0 0 0 1px color-mix(in oklab, currentColor 45%, transparent)\" }}\n          >\n            <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" aria-hidden>\n              {open ? <path d=\"M3.5 3.5l9 9M12.5 3.5l-9 9\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" /> : <path d=\"M2.5 6h11M2.5 10.5h7\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />}\n            </svg>\n          </button>\n        </div>\n      </div>\n      <div\n        id={sheetId}\n        className=\"ae-nv-sheet absolute inset-x-0 top-full z-10 @3xl:hidden\"\n        style={{ background: ground === \"night\" ? AE_NIGHT : AE_HAZE, color: ground === \"night\" ? AE_HAZE : AE_UMBER, clipPath: open ? \"inset(0 0 0 0)\" : \"inset(0 0 100% 0)\", visibility: open ? \"visible\" : \"hidden\" }}\n      >\n        <ul className=\"m-0 flex list-none flex-col p-0 px-5 pb-6 pt-2\">\n          {links.map((l) => (\n            <li key={l.label} style={{ boxShadow: \"inset 0 -1px 0 color-mix(in oklab, currentColor 16%, transparent)\" }}>\n              <a href={aeSafeHref(l.href)} onClick={go} className=\"flex items-center justify-between py-4 text-[30px] leading-none no-underline\" style={{ color: \"inherit\", fontWeight: 330, fontStretch: \"112.5%\", letterSpacing: \"-0.02em\" }}>\n                {l.label}\n                <AeArrow size={18} />\n              </a>\n            </li>\n          ))}\n          <li className=\"pt-5\">\n            <a href={aeSafeHref(cta.href)} onClick={go} className=\"ae-press inline-flex h-12 w-full items-center justify-center gap-2 rounded-full text-[15px] font-medium no-underline\" style={{ background: AE_EMBER, color: \"#1A0E07\", fontStretch: \"100%\" }}>\n              {cta.label}\n              <AeArrow />\n            </a>\n          </li>\n        </ul>\n      </div>\n    </nav>\n  );\n}\n","type":"registry:component","target":"components/viberdy/drift-nav.tsx"}],"meta":{"tier":"free","tags":["navbar","navigation","header","adaptive","glass","wind","mobile menu"],"href":"https://viberdy.dev/library/drift-nav","aiPrompt":"Build DriftNav, a navigation bar that reads the ground under it and swaps its ink, frosts once the page scrolls, and carries a wind vane; its link underlines are grains drifting downwind. Style: Aeolian (wind-blown sand as a material, warm, cinematic, low sun). Fixed palette, no theming: night #150E0A (deepest ground, dusk, footer), umber #2B1B12 (ink on light grounds), dune #B8743F (mid sand, marks on haze), sand #DDAA72 (lit sand, marks on night), haze #EFE2CD (the pale ground, and the colour a sandstorm turns everything), ember #E0602C (the one filled accent: CTA, knob, active; its label ink is #1A0E07). Umber on haze is 12.9:1, haze on night 15.6:1. Type: Archivo variable loaded with its wdth axis (next/font: Archivo({ subsets: [\"latin\"], axes: [\"wdth\"], variable: \"--font-ae\" })) and read as var(--font-ae, var(--font-display, \"Archivo\")); display is wide and light: weight 300-360 (320 section headings, 330 hero), font-stretch 112.5% (125% for big numerals, the brand and the footer wordmark), set with the font-stretch property (never font-variation-settings, so the canvas rasterizer can paint the same instance), tracking -0.02em to -0.04em, line-height 1-1.02 (hero 0.98); body copy 14.5-16.5px at stretch 100%. Accent: Instrument Serif italic (var(--font-ae-serif, var(--font-instrument-serif, \"Instrument Serif\")), weight 400, stretch 100%, tracking -0.01em), one word per line at most, sand on night and dune on haze. Labels and readouts: Geist Mono (var(--font-ae-mono, var(--font-geist-mono, \"Geist Mono\"))), 10-12px, uppercase, tracking 0.14-0.16em. Buttons are full pills (48px high, 14.5px, weight 500); cards are radius 22-28px. Every section root carries data-ae-ground=\"night\" | \"haze\" (a nav reads it). One focus ring through a .ae-scope root: 2px solid, offset 3px, !important so site rules cannot beat it, sand on night and umber on haze. Tabular figures on numbers; a press scales to 0.97 in 180ms; arrows slide 3px on hover; anything a frame loop writes carries data-ae-live and has transitions off. Scroll-told parts are driven by one number p (0..1) from the hook useAeDrive(ref, { mode: \"scroll\" | \"auto\", progress, still, period, measure }, render): in scroll mode a document-level capture-phase scroll listener (plus resize and a ResizeObserver, at most once per frame) measures the section and the value chases it, v += (target - v) * 0.14 per frame, snapping when within 0.0004; the nearest ancestor that really scrolls (overflow-y auto/scroll/overlay and scrollHeight > clientHeight) is found every frame, otherwise the window, so the same section works on a page and inside a scrolling box. measure is aePin (a tall pinned block: (view.top - rect.top) / (rect.height - view.height)) or aePass (a block crossing the view: (view.top + view.height - rect.top) / (rect.height + view.height)). Mode \"auto\" (previews, embeds) plays a slow loop once the section has been seen (IntersectionObserver, 200px margin): u = ((now - t0) % period) / period, p = u < 0.55 ? smoothstep(u / 0.55) : u < 0.7 ? 1 : u < 0.95 ? 1 - smoothstep((u - 0.7) / 0.25) : 0 (period default 16000 ms); the progress prop holds one moment; reduced motion renders the settled value still. render writes straight to the DOM; nothing re-renders React per frame. Pinned runs are height calc(var(--ae-vh, 100svh) * N) holding a sticky top-0 box of var(--ae-vh, 100svh), and only when mode is scroll, progress is unset and motion is not reduced; otherwise the section is height ?? var(--ae-vh, 100svh) (one screen) and its box fills it. Each file exports its component, its props interface and the AeGround / AeDriveMode types. Reduced motion (live, via useSyncExternalStore on prefers-reduced-motion) shows each section's settled state and turns every dissolve into a 220ms fade. Same-page links go through a safe-href helper (https, http, mailto, tel, same-site paths and anchors only; tabs and newlines stripped; backslashes and // refused, anything else becomes #) and glide (auto under reduced motion) to the [data-ae-anchor] target inside the nearest [data-ae-site]. React 19, Tailwind v4 with container queries (@container on the root, @3xl = 768px for the desktop layout; JS phone checks use a container width under 700px). No three.js, no images, no assets: everything is hand-written WebGL2 or 2D canvas, WebGL canvases are aria-hidden, and every WebGL part falls back to a painted gradient.\n\nThe wind (shared module store, not React state): aeWind = { angle: 205, speed: 26, subs }. angle is the way it blows TOWARD, in degrees on the ground (0 = right/east, 90 = away from you/north, 180 = left, 270 = toward you), normalised to 0..360; speed is km/h clamped 0..60. aeSetWind({ angle?, speed? }) writes and calls every subscriber; aeOnWind(fn) subscribes and returns an unsubscribe; listeners write to the DOM, never to React state. aeWindScreen(angle) is the wind as a unit vector on the screen: x = cos a, y = -sin a * 0.55 (away from you reads as rising, flattened because the ground is seen low), normalised. aeWindFrom(angle): toward = (90 - angle) mod 360, from = (toward + 180) mod 360, index round(from / 22.5) mod 16 into N NNE NE ENE E ESE SE SSE S SSW SW WSW W WNW NW NNW. aeWindText = \"From ENE · 26 km/h\" (the default 205 reads ENE). The store lives on globalThis.__aeolianWind (created once with ??=, so parts pasted as separate files still share one wind). Every engine on the page reads this one store.\n\nGround reading: on every scroll frame (the kit's scroll hook, which hears any scroller) the bar calls document.elementsFromPoint at its own centre point, skips its own elements, and takes the nearest ancestor with [data-ae-ground] whose value is \"night\" or \"haze\" (else the fallback prop, default night; when nothing under the bar carries a ground the bar is \"bare\" and wears its glass even at the top so the ink always reads). A MutationObserver on data-ae-ground attributes (subtree, inside the nearest [data-ae-site] or document.body, batched to one rAF) re-reads when a section changes its ground without a scroll (the hero's whiteout eases in). The bar itself carries data-ae-ground = the ground it picked. Inks: night: haze text, ember CTA with ink #1A0E07, glass rgba(21,14,10,.62); haze: umber text, umber CTA with haze label, glass rgba(239,226,205,.72). The crescent mark (24x24 SVG path, plus a half-opacity horn) is sand on night and dune on haze.\nHeight 72px, 56px once scrollTop (of the nearest scroller, else window) exceeds 80, when it also fills with its glass (backdrop-filter blur(14px) saturate(1.2)) and draws an inset 1px hairline (currentColor 14%); transitions 260ms colour/background, 280ms height with cubic-bezier(.2,.8,.2,1). Position \"sticky\" is sticky top-0 z-50; \"static\" leaves placement to the page (the Barchan site floats it over the hero). It is an @container nav with aria-label \"Main\". Layout: padding 20px sides (3.2cqw from @3xl); left, the mark (22px) and the brand at 15px, uppercase, tracking 0.2em, weight 500, stretch 125%; centre (from @3xl) the links at 14px, weight 400, gap 36px; right (from @3xl) the vane, an ember or umber CTA pill (40px high, 13.5px, with a 12px arrow; shown from @md), and under @3xl a 40px round outlined menu button (inset 1px ring at 45%).\nUnderline: each link's ::after is a 3px tall strip at left/right -2px, bottom -7px, made of two radial-gradient grain rows (currentColor dots of radius 0.9px at a 7x3 tile and 0.6px at a 5x3 tile offset 2px 1px), masked to fade 0 to 100% over the first and last 22%; on hover or keyboard focus it fades to 0.9 opacity (220ms) and animates background-position by calc(var(--ae-drift) * 35px) 0 and calc(var(--ae-drift) * 27px) 1px over 1.1s linear, infinite; the bar sets --ae-drift to -1 when aeWindScreen()[0] < 0 and 1 otherwise, so grains drift the way the wind blows.\nVane: an 18px circle (currentColor at 30%) with an arrow group rotated by -angle about (9, 9), and a mono 10.5px readout \"ENE 26\" = aeWindFrom() + \" \" + round(speed), both written to the DOM on every wind change; sr-only \"km/h wind\"; title \"The page's wind\"; hidden under @3xl. Menu sheet: absolute, top-full, full width, night or haze background to match; clip-path inset(0 0 100% 0) to inset(0) in 360ms, visibility toggled; links at 30px, weight 330, stretch 112.5%, tracking -0.02em with 18px arrows and 16% hairlines, then a full-width ember CTA (48px). The button has aria-expanded, aria-controls (useId), a label that flips Open/Close menu, and a menu/close icon; Escape closes it and returns focus to the button; choosing a link first moves focus to the menu button (preventScroll) and then closes it; a ResizeObserver (while the sheet is open) closes it when the bar widens to 768px or more. The vane effect depends on the vane prop. Same-page links glide to the [data-ae-anchor] target. Reduced motion removes the transitions and the underline animation. Props: brand (\"Barchan\"), position (\"sticky\" | \"static\"), fallback (\"night\" | \"haze\"), links (Camp #camp, Routes #routes, Stays #stays, Request #request), cta ({ label: \"Reserve\", href: \"#stays\" }), vane (true), className, style.","license":"https://viberdy.dev/license"}}