Hi-Vis Nav
A full-width bar that reads the ground under it, black, safety orange or bone, and swaps its ink and button to match; it tightens after the first scroll.
DOM + CSS0 deps1 file · 15 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { CSSProperties, RefObject } from "react";
/** Safety orange as a field colour. Black on it reads at 6.3:1. */
const HV_ORANGE = "#FF5B14";
/** The hot highlight, for orange marks on black. */
const HV_HOT = "#FF7A1A";
const HV_BLACK = "#0B0A09";
const HV_BONE = "#EFEBE4";
/**
* The display face: Archivo's width axis does the shouting (wdth 62..125).
* Load it as a variable font with the width axis and expose it as --font-hv,
* e.g. in Next: Archivo({ subsets: ["latin"], axes: ["wdth"], variable: "--font-hv" }).
*/
const HV_DISPLAY = 'var(--font-hv, "Archivo"), "Archivo", "Arial Narrow", ui-sans-serif, system-ui, sans-serif';
/** Labels and readouts: Geist Mono as --font-hv-mono (any monospace works). */
const HV_MONO = 'var(--font-hv-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. */
type HvGround = "black" | "orange" | "bone";
const hvReducedSnap = () => 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 hvScrollParent(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 HvFrame {
/** 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 useHvScrollFrame<T extends HTMLElement>(ref: RefObject<T | null>, apply: (f: HvFrame) => 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 = hvScrollParent(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 hvSafeHref(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:)/i.test(h) ? h : "#";
}
/** Scroll whatever scrolls the link to a same-page anchor ([data-hv-anchor]); anything else is left to the browser. */
function hvGo(e: { currentTarget: HTMLAnchorElement; preventDefault: () => void }) {
const href = e.currentTarget.getAttribute("href") ?? "";
if (!href.startsWith("#") || href.length < 2) return;
const root = e.currentTarget.closest("[data-hv-site]") ?? document;
const target = root.querySelector<HTMLElement>('[data-hv-anchor="' + CSS.escape(href.slice(1)) + '"]');
if (!target) return;
e.preventDefault();
const sp = hvScrollParent(target);
const smooth: ScrollBehavior = hvReducedSnap() ? "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, in the ink of the
* ground it sits on), tabular figures, the press, and transitions off for
* anything a frame loop writes.
*/
const HV_CSS =
".hv-scope{--hv-ring:" + HV_HOT + "}" +
".hv-scope[data-hv-ground=orange],.hv-scope [data-hv-ground=orange]{--hv-ring:" + HV_BLACK + "}" +
".hv-scope[data-hv-ground=bone],.hv-scope [data-hv-ground=bone]{--hv-ring:" + HV_BLACK + "}" +
".hv-scope :focus-visible{outline:2px solid var(--hv-ring)!important;outline-offset:3px!important}" +
".hv-scope .hv-num{font-variant-numeric:tabular-nums}" +
".hv-scope [data-hv-live]{transition:none!important}" +
".hv-scope .hv-press{transition:transform 160ms cubic-bezier(.2,.8,.2,1),background-color 160ms,color 160ms}" +
".hv-scope .hv-press:active{transform:scale(.97)}" +
".hv-scope .hv-arr{transition:transform 220ms cubic-bezier(.2,.8,.2,1)}.hv-scope a:hover .hv-arr,.hv-scope button:hover .hv-arr{transform:translateX(3px)}" +
"@media (prefers-reduced-motion:reduce){.hv-scope .hv-press,.hv-scope .hv-arr{transition:none}}";
/** The kit's arrow: slides on hover. */
function HvArrow({ size = 14 }: { size?: number }) {
return (
<svg className="hv-arr" width={size} height={size} viewBox="0 0 14 14" aria-hidden>
<path d="M2 7h9M7.5 3.5L11 7l-3.5 3.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="square" />
</svg>
);
}
/** Ardent's mark: a shock diamond, the bright knot in a rocket plume. Takes the current colour. */
function HvMark({ size = 22 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden>
<path d="M12 1.5 L19 12 L12 22.5 L5 12 Z" fill="currentColor" />
<path d="M12 7.2 L15.2 12 L12 16.8 L8.8 12 Z" fill="var(--hv-mark-cut, #0B0A09)" />
</svg>
);
}
/** Ink for each ground: text, the CTA's fill and its label. */
const NV_INK: Record<HvGround, { ink: string; cta: string; ctaInk: string; mark: string }> = {
black: { ink: HV_BONE, cta: HV_ORANGE, ctaInk: HV_BLACK, mark: HV_HOT },
orange: { ink: HV_BLACK, cta: HV_BLACK, ctaInk: HV_ORANGE, mark: HV_BLACK },
bone: { ink: HV_BLACK, cta: HV_ORANGE, ctaInk: HV_BLACK, mark: HV_ORANGE },
};
/** The bar fills with its ground once the page has scrolled, so nothing passes behind the links. */
const NV_GROUND: Record<HvGround, string> = { black: HV_BLACK, orange: HV_ORANGE, bone: HV_BONE };
const NV_CSS =
".hv-nv{transition:color 220ms,background-color 220ms,height 260ms cubic-bezier(.2,.8,.2,1),box-shadow 220ms}" +
".hv-nv a,.hv-nv button{transition:color 220ms,background-color 220ms}" +
".hv-nv-link{position:relative;text-decoration:none}" +
".hv-nv-link::after{content:'';position:absolute;left:0;right:0;bottom:-5px;height:2px;background:currentColor;transform:scaleX(0);transform-origin:left;transition:transform 220ms cubic-bezier(.2,.8,.2,1)}" +
".hv-nv-link:hover::after,.hv-nv-link:focus-visible::after{transform:scaleX(1)}" +
".hv-nv-sheet{transition:clip-path 320ms cubic-bezier(.2,.8,.2,1)}" +
"@media (prefers-reduced-motion:reduce){.hv-nv,.hv-nv a,.hv-nv button,.hv-nv-sheet,.hv-nv-link::after{transition:none}}";
export interface HiVisNavProps {
brand?: string;
/** "sticky" sticks to the top of its scroller; "static" leaves placement to you (the Ardent site floats it over the hero). */
position?: "sticky" | "static";
/** The ground to assume where no section under the bar carries data-hv-ground. */
fallback?: HvGround;
links?: { label: string; href: string }[];
cta?: { label: string; href: string };
className?: string;
style?: CSSProperties;
}
/**
* HiVisNav — a full-width bar that reads the ground under it.
*/
export function HiVisNav({
brand = "Ardent",
links = [
{ label: "Engine", href: "#engine" },
{ label: "Specs", href: "#specs" },
{ label: "Manifest", href: "#manifest" },
{ label: "Book", href: "#book" },
],
cta = { label: "Book a slot", href: "#book" },
position = "sticky",
fallback = "black",
className = "",
style,
}: HiVisNavProps) {
const ref = useRef<HTMLElement>(null);
const [ground, setGround] = useState<HvGround>(fallback);
const [compact, setCompact] = useState(false);
const [open, setOpen] = useState(false);
const sheetId = useId();
const menuRef = useRef<HTMLButtonElement>(null);
// Which ground is under the bar: whatever the page shows at the bar's centre line, not the bar itself.
useHvScrollFrame(ref, () => {
const nav = ref.current;
if (!nav) return;
const r = nav.getBoundingClientRect();
const y = r.top + r.height / 2;
let g: HvGround = fallback;
for (const el of document.elementsFromPoint(r.left + r.width * 0.5, y)) {
if (nav.contains(el)) continue;
const host = el.closest<HTMLElement>("[data-hv-ground]");
const v = host?.dataset.hvGround;
if (v === "black" || v === "orange" || v === "bone") {
g = v;
break;
}
}
setGround(g);
const sp = hvScrollParent(nav);
const top = sp ? sp.scrollTop : window.scrollY;
setCompact(top > 80);
});
// A section can change its ground after the scroll has stopped (the Ignition Hero's flood eases in), so listen for that too.
useEffect(() => {
const nav = ref.current;
if (!nav || typeof MutationObserver === "undefined") return;
let raf = 0;
const read = () => {
raf = 0;
const r = nav.getBoundingClientRect();
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-hv-ground]")?.dataset.hvGround;
if (v === "black" || v === "orange" || v === "bone") {
setGround(v);
return;
}
}
};
const mo = new MutationObserver(() => {
if (!raf) raf = requestAnimationFrame(read);
});
mo.observe(nav.closest("[data-hv-site]") ?? document.body, { subtree: true, attributes: true, attributeFilter: ["data-hv-ground"] });
return () => {
mo.disconnect();
if (raf) cancelAnimationFrame(raf);
};
}, []);
useEffect(() => {
if (!open) return;
const key = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
menuRef.current?.focus();
}
};
document.addEventListener("keydown", key);
return () => document.removeEventListener("keydown", key);
}, [open]);
const c = NV_INK[ground];
const go = (e: { currentTarget: HTMLAnchorElement; preventDefault: () => void }) => {
setOpen(false);
hvGo(e);
};
return (
<nav
ref={ref}
aria-label="Main"
data-hv-ground={ground}
className={"hv-scope hv-nv @container w-full " + (position === "sticky" ? "sticky top-0 z-50 " : "relative ") + className}
style={{ color: c.ink, background: compact || open ? NV_GROUND[ground] : "transparent", height: compact ? 52 : 68, fontFamily: HV_DISPLAY, boxShadow: compact ? "inset 0 -1px 0 color-mix(in oklab, currentColor 18%, transparent)" : "none", ...style }}
>
<style>{HV_CSS + NV_CSS}</style>
<div className="flex h-full w-full items-center justify-between gap-6 px-5 @3xl:px-[3cqw]">
<a href="#top" onClick={go} className="flex items-center gap-2.5 no-underline" style={{ color: "inherit", "--hv-mark-cut": ground === "orange" ? HV_ORANGE : ground === "bone" ? HV_BONE : HV_BLACK } as CSSProperties}>
<span style={{ color: c.mark }}>
<HvMark size={20} />
</span>
<span className="text-[19px] uppercase leading-none tracking-[0.02em]" style={{ fontWeight: 850, fontVariationSettings: '"wdth" 112' }}>
{brand}
</span>
</a>
<ul className="m-0 hidden list-none items-center gap-8 p-0 @3xl:flex">
{links.map((l) => (
<li key={l.label}>
<a href={hvSafeHref(l.href)} onClick={go} className="hv-nv-link text-[12px] font-medium uppercase tracking-[0.14em]" style={{ color: "inherit", fontFamily: HV_MONO }}>
{l.label}
</a>
</li>
))}
</ul>
<div className="flex items-center gap-2">
<a
href={hvSafeHref(cta.href)}
onClick={go}
className="hv-press hidden h-10 items-center gap-2 px-4 text-[13px] font-bold uppercase tracking-[0.06em] no-underline @md:inline-flex"
style={{ background: c.cta, color: c.ctaInk }}
>
{cta.label}
<HvArrow 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="hv-press inline-flex h-10 w-10 items-center justify-center border-0 @3xl:hidden"
style={{ background: "transparent", color: "inherit", boxShadow: "inset 0 0 0 1.5px currentColor" }}
>
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden>
{open ? <path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="2" /> : <path d="M2 5h12M2 11h12" stroke="currentColor" strokeWidth="2" />}
</svg>
</button>
</div>
</div>
<div
id={sheetId}
className="hv-nv-sheet absolute inset-x-0 top-full z-10 @3xl:hidden"
style={{ background: ground === "black" ? HV_ORANGE : HV_BLACK, color: ground === "black" ? HV_BLACK : HV_ORANGE, 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 py-4">
{links.map((l) => (
<li key={l.label} style={{ boxShadow: "inset 0 -1px 0 color-mix(in oklab, currentColor 22%, transparent)" }}>
<a href={hvSafeHref(l.href)} onClick={go} className="flex items-center justify-between py-3 text-[34px] uppercase leading-none no-underline" style={{ color: "inherit", fontWeight: 900, fontVariationSettings: '"wdth" 70' }}>
{l.label}
<HvArrow size={18} />
</a>
</li>
))}
<li className="pt-4">
<a href={hvSafeHref(cta.href)} onClick={go} className="hv-press inline-flex h-12 w-full items-center justify-center gap-2 text-[14px] font-bold uppercase tracking-[0.06em] no-underline" style={{ background: ground === "black" ? HV_BLACK : HV_ORANGE, color: ground === "black" ? HV_ORANGE : HV_BLACK }}>
{cta.label}
<HvArrow />
</a>
</li>
</ul>
</div>
</nav>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
A sticky nav that looks right on one section usually looks wrong on the next. This bar checks what is under it as the page scrolls and changes to suit: bone type and an orange button over black, black type and a black button over safety orange, black over bone. It follows grounds that change mid-section too. After the first 80px it tightens and draws a hairline. Under 768px the links fold into a full-width sheet with big condensed type, closed by its button or Escape.
Curator’s note
Free. On every scroll frame the bar asks the page what is under its centre line (elementsFromPoint, skipping itself) and reads the nearest data-hv-ground, so it follows sections that change colour as they scroll, like the Ignition Hero's flood. Every Hi-Vis entry is cut byte for byte from one site (Ardent, a made-up engine maker) and shares its fixed palette: safety orange #FF5B14 as a field, warm black, bone, Archivo's width axis for the shouting.

