Skip to content

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

FreeAeolianminimal
Preview

Props

Wind vane
No runtime dependencies
"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.

Pairs well with

Ultra Pill Nav

components

Hi-Vis Nav

components