Drift Nav
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.
DOM + CSS0 deps1 file · 21 KB TSXreduced-motion aware
Props
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { CSSProperties, RefObject } from "react";
/**
* DRIFT NAV.
* 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.
*
* FONTS. Archivo with its width axis for display, Instrument Serif italic for
* the accent word, Geist Mono for labels. In Next: Archivo({ subsets:
* ["latin"], axes: ["wdth"], variable: "--font-ae" }), Instrument_Serif({
* subsets: ["latin"], weight: "400", style: "italic", variable:
* "--font-ae-serif" }) and Geist_Mono({ subsets: ["latin"], variable:
* "--font-ae-mono" }) on <html>. Without them it falls back to system fonts.
*
* CLIENT. The file starts with "use client".
*
* WIND. Every Aeolian entry reads one page-wide wind (aeWind on globalThis),
* so separately pasted entries still turn together.
*/
/** The deepest ground: dusk, the footer. */
const AE_NIGHT = "#150E0A";
/** Ink on the light grounds. */
const AE_UMBER = "#2B1B12";
const AE_DUNE = "#B8743F";
/** Lit sand: marks on the night ground. */
const AE_SAND = "#DDAA72";
/** The pale ground, and the colour a sandstorm turns everything. */
const AE_HAZE = "#EFE2CD";
/** The one filled accent: the low sun. */
const AE_EMBER = "#E0602C";
/**
* The display face: Archivo, wide and light. Set its width with font-stretch
* (112.5% or 125%), not font-variation-settings, so a canvas can paint the
* same instance. In Next: Archivo({ subsets: ["latin"], axes: ["wdth"], variable: "--font-ae" }).
*/
const AE_DISPLAY = 'var(--font-ae, var(--font-display, "Archivo")), "Archivo", ui-sans-serif, system-ui, sans-serif';
/** Labels and readouts. */
const AE_MONO = 'var(--font-ae-mono, var(--font-geist-mono, "Geist Mono")), "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, monospace';
/** Which ground a section sits on; the nav reads it to pick its ink. */
export type AeGround = "night" | "haze";
const aeReducedSnap = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/**
* The scrolling box an element lives in: the nearest ancestor that actually
* scrolls, or null for the window. Resolved on every frame, because a box only
* becomes a scroller once its content is taller than it.
*/
function aeScrollParent(el: Element | null): HTMLElement | null {
let n = el?.parentElement ?? null;
while (n && n !== document.body && n !== document.documentElement) {
const oy = getComputedStyle(n).overflowY;
if ((oy === "auto" || oy === "scroll" || oy === "overlay") && n.scrollHeight > n.clientHeight + 1) return n;
n = n.parentElement;
}
return null;
}
interface AeFrame {
/** The element's box, in viewport pixels. */
rect: DOMRect;
/** The visible part of whatever scrolls it: the scroller's box, or the window. */
view: { top: number; height: number; left: number; width: number };
}
/**
* Run `apply` with the element's position whenever anything scrolls or
* resizes, at most once per frame. One capture-phase scroll listener hears
* every scroller, so the same section works on a page and inside a scrolling
* preview. `apply` writes to the DOM; nothing here re-renders React.
*/
function useAeScrollFrame<T extends HTMLElement>(ref: RefObject<T | null>, apply: (f: AeFrame) => void) {
const applyRef = useRef(apply);
useEffect(() => {
applyRef.current = apply;
});
useEffect(() => {
let raf = 0;
const run = () => {
raf = 0;
const el = ref.current;
if (!el) return;
const sp = aeScrollParent(el);
const r = sp ? sp.getBoundingClientRect() : null;
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 };
applyRef.current({ rect: el.getBoundingClientRect(), view });
};
const kick = () => {
if (!raf) raf = requestAnimationFrame(run);
};
document.addEventListener("scroll", kick, { capture: true, passive: true });
window.addEventListener("resize", kick);
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(kick) : null;
if (ref.current && ro) ro.observe(ref.current);
run();
return () => {
document.removeEventListener("scroll", kick, { capture: true });
window.removeEventListener("resize", kick);
ro?.disconnect();
if (raf) cancelAnimationFrame(raf);
};
}, [ref]);
}
/** Links built from props: https, http, mailto, same-site paths and anchors only (tabs and newlines stripped, backslashes refused). */
function aeSafeHref(href: string): string {
const h = href.replace(/[\t\n\r]/g, "").trim();
if (!h || h.includes(String.fromCharCode(92))) return "#";
if (h.startsWith("#") || (h.startsWith("/") && !h.startsWith("//"))) return h;
return /^(https?:|mailto:|tel:)/i.test(h) ? h : "#";
}
/** Scroll whatever scrolls the link to a same-page anchor ([data-ae-anchor]); anything else is left to the browser. */
function aeGo(e: { currentTarget: HTMLAnchorElement; preventDefault: () => void }) {
const href = e.currentTarget.getAttribute("href") ?? "";
if (!href.startsWith("#") || href.length < 2) return;
const root = e.currentTarget.closest("[data-ae-site]") ?? document;
const target = root.querySelector<HTMLElement>('[data-ae-anchor="' + CSS.escape(href.slice(1)) + '"]');
if (!target) return;
e.preventDefault();
const sp = aeScrollParent(target);
const smooth: ScrollBehavior = aeReducedSnap() ? "auto" : "smooth";
if (sp) sp.scrollTo({ top: sp.scrollTop + target.getBoundingClientRect().top - sp.getBoundingClientRect().top, behavior: smooth });
else window.scrollTo({ top: window.scrollY + target.getBoundingClientRect().top, behavior: smooth });
}
/**
* The kit's scoped CSS: one focus ring (2px, offset 3px, sand on night and
* umber on haze), tabular figures, the press, and transitions off for
* anything a frame loop writes.
*/
const AE_CSS =
".ae-scope{--ae-ring:" + AE_SAND + "}" +
".ae-scope[data-ae-ground=haze],.ae-scope [data-ae-ground=haze]{--ae-ring:" + AE_UMBER + "}" +
".ae-scope :focus-visible{outline:2px solid var(--ae-ring)!important;outline-offset:3px!important}" +
".ae-scope .ae-num{font-variant-numeric:tabular-nums}" +
".ae-scope [data-ae-live]{transition:none!important}" +
".ae-scope .ae-press{transition:transform 180ms cubic-bezier(.2,.8,.2,1),background-color 180ms,color 180ms,border-color 180ms}" +
".ae-scope .ae-press:active{transform:scale(.97)}" +
".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)}" +
"@media (prefers-reduced-motion:reduce){.ae-scope .ae-press,.ae-scope .ae-arr{transition:none}}";
/** The kit's arrow: slides on hover. */
function AeArrow({ size = 14 }: { size?: number }) {
return (
<svg className="ae-arr" width={size} height={size} viewBox="0 0 14 14" aria-hidden>
<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" />
</svg>
);
}
/** Barchan's mark: a crescent dune seen from above, horns downwind. Takes the current colour. */
function AeMark({ size = 22 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden>
<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" />
<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" />
</svg>
);
}
/**
* The page's wind. `angle` is the way it blows TOWARD, in degrees on the
* ground: 0 = to the right (east), 90 = away from you (north), 180 = left,
* 270 = toward you. `speed` is in km/h. The dune stage, the grains, the dust
* of every dissolve, the nav's vane and the footer all read this one store,
* so turning the rose in the hero turns every grain on the page. It lives on
* globalThis, so entries pasted as separate files still share one wind.
*/
const 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() });
/** Hear every wind change; returns an unsubscribe. Listeners write to the DOM, not to React state. */
function aeOnWind(fn: () => void): () => void {
aeWind.subs.add(fn);
return () => {
aeWind.subs.delete(fn);
};
}
/**
* The wind as a direction on a flat screen: x right, y down. Wind blowing
* away from you reads as rising, toward you as falling, a little flattened
* (the ground is seen at a low angle). Unit length.
*/
function aeWindScreen(angle = aeWind.angle): [number, number] {
const a = (angle * Math.PI) / 180;
const x = Math.cos(a);
const y = -Math.sin(a) * 0.55;
const l = Math.hypot(x, y) || 1;
return [x / l, y / l];
}
const AE_POINTS = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
/** Where the wind comes FROM, as a compass point (north is away from you): blowing to the right is a westerly. */
function aeWindFrom(angle = aeWind.angle): string {
const toward = (((90 - angle) % 360) + 360) % 360;
const from = (toward + 180) % 360;
return AE_POINTS[Math.round(from / 22.5) % 16] ?? "N";
}
/** Ink for each ground: text, the CTA's fill and its label, and the glass the bar fills with once scrolled. */
const NV_INK: Record<AeGround, { ink: string; cta: string; ctaInk: string; glass: string }> = {
night: { ink: AE_HAZE, cta: AE_EMBER, ctaInk: "#1A0E07", glass: "rgba(21,14,10,.62)" },
haze: { ink: AE_UMBER, cta: AE_UMBER, ctaInk: AE_HAZE, glass: "rgba(239,226,205,.72)" },
};
const NV_CSS =
".ae-nv{transition:color 260ms,background-color 260ms,height 280ms cubic-bezier(.2,.8,.2,1),box-shadow 260ms}" +
".ae-nv a,.ae-nv button{transition:color 260ms,background-color 260ms}" +
".ae-nv-link{position:relative;text-decoration:none}" +
// The underline is a row of grains drifting downwind (the bar sets --ae-drift to the wind's sign).
".ae-nv-link::after{content:'';position:absolute;left:-2px;right:-2px;bottom:-7px;height:3px;opacity:0;" +
"background-image:radial-gradient(circle,currentColor 0 .9px,transparent 1.2px),radial-gradient(circle,currentColor 0 .6px,transparent .9px);" +
"background-size:7px 3px,5px 3px;background-position:0 0,2px 1px;" +
"-webkit-mask-image:linear-gradient(90deg,transparent,#000 22%,#000 78%,transparent);mask-image:linear-gradient(90deg,transparent,#000 22%,#000 78%,transparent);" +
"transition:opacity 220ms}" +
".ae-nv-link:hover::after,.ae-nv-link:focus-visible::after{opacity:.9;animation:ae-nv-drift 1.1s linear infinite}" +
"@keyframes ae-nv-drift{to{background-position:calc(var(--ae-drift,1) * 35px) 0,calc(var(--ae-drift,1) * 27px) 1px}}" +
".ae-nv-sheet{transition:clip-path 360ms cubic-bezier(.2,.8,.2,1)}" +
"@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}}";
export interface DriftNavProps {
brand?: string;
/** "sticky" sticks to the top of its scroller; "static" leaves placement to you (the Barchan site floats it over the hero). */
position?: "sticky" | "static";
/** 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. */
fallback?: AeGround;
links?: { label: string; href: string }[];
cta?: { label: string; href: string };
/** Show the live wind vane and readout. */
vane?: boolean;
className?: string;
style?: CSSProperties;
}
/**
* DriftNav — a full-width bar that reads the ground under it (night or haze)
* and swaps its ink, frosts once the page scrolls, and carries a wind vane
* that turns with the page's wind. Link underlines are grains drifting
* downwind.
*/
export function DriftNav({
brand = "Barchan",
links = [
{ label: "Camp", href: "#camp" },
{ label: "Routes", href: "#routes" },
{ label: "Stays", href: "#stays" },
{ label: "Request", href: "#request" },
],
cta = { label: "Reserve", href: "#stays" },
position = "sticky",
fallback = "night",
vane = true,
className = "",
style,
}: DriftNavProps) {
const ref = useRef<HTMLElement>(null);
const [ground, setGround] = useState<AeGround>(fallback);
const [compact, setCompact] = useState(false);
// No section under the bar says what it is (a page without data-ae-ground): wear the glass so the ink always reads.
const [bare, setBare] = useState(false);
const [open, setOpen] = useState(false);
const sheetId = useId();
const menuRef = useRef<HTMLButtonElement>(null);
const read = useCallback(() => {
const nav = ref.current;
if (!nav) return;
const r = nav.getBoundingClientRect();
let g: AeGround = fallback;
let found = false;
for (const el of document.elementsFromPoint(r.left + r.width * 0.5, r.top + r.height / 2)) {
if (nav.contains(el)) continue;
const v = el.closest<HTMLElement>("[data-ae-ground]")?.dataset.aeGround;
if (v === "night" || v === "haze") {
g = v;
found = true;
break;
}
}
setGround(g);
setBare(!found);
}, [fallback]);
// Which ground is under the bar: whatever the page shows at the bar's centre line, not the bar itself.
useAeScrollFrame(ref, () => {
const nav = ref.current;
if (!nav) return;
read();
const sp = aeScrollParent(nav);
setCompact((sp ? sp.scrollTop : window.scrollY) > 80);
});
// 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.
useEffect(() => {
const nav = ref.current;
if (!nav || typeof MutationObserver === "undefined") return;
let raf = 0;
const mo = new MutationObserver(() => {
if (!raf)
raf = requestAnimationFrame(() => {
raf = 0;
read();
});
});
mo.observe(nav.closest("[data-ae-site]") ?? document.body, { subtree: true, attributes: true, attributeFilter: ["data-ae-ground"] });
return () => {
mo.disconnect();
if (raf) cancelAnimationFrame(raf);
};
}, [read]);
// The vane and the underline drift follow the wind, written to the DOM.
useEffect(() => {
const nav = ref.current;
if (!nav) return;
const draw = () => {
nav.style.setProperty("--ae-drift", aeWindScreen()[0] < 0 ? "-1" : "1");
nav.querySelectorAll<SVGElement>("[data-nv-vane]").forEach((v) => v.setAttribute("transform", "rotate(" + (-aeWind.angle).toFixed(1) + " 9 9)"));
nav.querySelectorAll<HTMLElement>("[data-nv-wind]").forEach((t) => {
t.textContent = aeWindFrom() + " " + Math.round(aeWind.speed);
});
};
draw();
return aeOnWind(draw);
}, [vane]);
useEffect(() => {
if (!open) return;
const key = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
menuRef.current?.focus();
}
};
document.addEventListener("keydown", key);
// Widened past the phone layout with the sheet open: the sheet is gone, so is "open".
const nav = ref.current;
const ro = nav && typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => { if (nav.clientWidth >= 768) setOpen(false); }) : null;
if (nav) ro?.observe(nav);
return () => {
document.removeEventListener("keydown", key);
ro?.disconnect();
};
}, [open]);
const c = NV_INK[ground];
const go = (e: { currentTarget: HTMLAnchorElement; preventDefault: () => void }) => {
// Leaving the sheet hands focus back to the menu button, not to the page body.
if (open) menuRef.current?.focus({ preventScroll: true });
setOpen(false);
aeGo(e);
};
return (
<nav
ref={ref}
aria-label="Main"
data-ae-ground={ground}
className={"ae-scope ae-nv @container w-full " + (position === "sticky" ? "sticky top-0 z-50 " : "relative ") + className}
style={{
color: c.ink,
background: compact || open || bare ? c.glass : "transparent",
backdropFilter: compact || open || bare ? "blur(14px) saturate(1.2)" : "none",
WebkitBackdropFilter: compact || open || bare ? "blur(14px) saturate(1.2)" : "none",
height: compact ? 56 : 72,
fontFamily: AE_DISPLAY,
boxShadow: compact ? "inset 0 -1px 0 color-mix(in oklab, currentColor 14%, transparent)" : "none",
...style,
}}
>
<style>{AE_CSS + NV_CSS}</style>
<div className="flex h-full w-full items-center justify-between gap-6 px-5 @3xl:px-[3.2cqw]">
<a href="#top" onClick={go} className="flex items-center gap-2.5 no-underline" style={{ color: "inherit" }}>
<span style={{ color: ground === "night" ? AE_SAND : AE_DUNE }}>
<AeMark size={22} />
</span>
<span className="text-[15px] uppercase leading-none tracking-[0.2em]" style={{ fontWeight: 500, fontStretch: "125%" }}>
{brand}
</span>
</a>
<ul className="m-0 hidden list-none items-center gap-9 p-0 @3xl:flex">
{links.map((l) => (
<li key={l.label}>
<a href={aeSafeHref(l.href)} onClick={go} className="ae-nv-link text-[14px]" style={{ color: "inherit", fontStretch: "100%", fontWeight: 400 }}>
{l.label}
</a>
</li>
))}
</ul>
<div className="flex items-center gap-3">
{vane ? (
<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">
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden>
<circle cx="9" cy="9" r="8" fill="none" stroke="currentColor" strokeOpacity=".3" />
<g data-nv-vane="" transform={"rotate(" + (-aeWind.angle).toFixed(1) + " 9 9)"}>
<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" />
</g>
</svg>
<span data-nv-wind="" className="ae-num">
{aeWindFrom() + " " + Math.round(aeWind.speed)}
</span>
<span className="sr-only">km/h wind</span>
</span>
) : null}
<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%" }}>
{cta.label}
<AeArrow size={12} />
</a>
<button
ref={menuRef}
type="button"
aria-expanded={open}
aria-controls={sheetId}
aria-label={open ? "Close menu" : "Open menu"}
onClick={() => setOpen((o) => !o)}
className="ae-press inline-flex h-10 w-10 items-center justify-center rounded-full border-0 @3xl:hidden"
style={{ background: "transparent", color: "inherit", boxShadow: "inset 0 0 0 1px color-mix(in oklab, currentColor 45%, transparent)" }}
>
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden>
{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" />}
</svg>
</button>
</div>
</div>
<div
id={sheetId}
className="ae-nv-sheet absolute inset-x-0 top-full z-10 @3xl:hidden"
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" }}
>
<ul className="m-0 flex list-none flex-col p-0 px-5 pb-6 pt-2">
{links.map((l) => (
<li key={l.label} style={{ boxShadow: "inset 0 -1px 0 color-mix(in oklab, currentColor 16%, transparent)" }}>
<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" }}>
{l.label}
<AeArrow size={18} />
</a>
</li>
))}
<li className="pt-5">
<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%" }}>
{cta.label}
<AeArrow />
</a>
</li>
</ul>
</div>
</nav>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
The bar for the Aeolian site. On a dark ground it is haze type with an ember button; on the pale ground it turns umber with an umber button, decided by whatever section is actually under it. Once the page has scrolled it frosts into glass. Point at a link and its underline is a line of grains drifting the way the wind blows; on the right a small vane turns with the page's wind and says where it is from. On a phone it folds into a menu sheet with large links.
Curator’s note
Free. The bar looks under its own centre line for the nearest data-ae-ground (night or haze) on every scroll frame and whenever a section changes its ground, so it turns from haze ink to umber when the hero's frame fades to the haze ground. The underline is two layered radial-gradient grain rows animated along the wind's sign. The vane and readout follow the kit's wind store. Every Aeolian entry is cut byte for byte from one site (Barchan, a made-up camp that moves with the dunes) and shares its light and its wind: sand, umber and haze with one ember accent, Archivo set wide and light, Instrument Serif italic for one word, and a single page-wide wind that every engine reads.

