Ardent Footer
A black footer with a closing call, link columns and the brand set edge to edge across the bottom in rising heat haze.
DOM + CSS0 deps1 file · 15 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties } 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';
const hvClamp = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v);
function hvReducedSub(fn: () => void) {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
}
const hvReducedSnap = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/** True when the reader asked for reduced motion. Live: follows the system setting as it changes. */
function useHvReduced(): boolean {
return useSyncExternalStore(hvReducedSub, hvReducedSnap, () => false);
}
/**
* 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;
}
/** 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>
);
}
export interface HeatHazeHeadingProps {
text?: string;
/** Heading level for the page outline. */
as?: "h1" | "h2" | "h3" | "p";
/** The ground it sits on: orange type on black, or black type on orange. */
ground?: "black" | "orange";
/** How hard the air shimmers at rest, 0..1. The pointer adds more where it is. */
intensity?: number;
/** Width axis, 62 (condensed) to 125 (wide). */
width?: number;
className?: string;
style?: CSSProperties;
}
/**
* HeatHazeHeading — a line of type that sits in rising heat.
*
* The type is SVG text fitted to its box (the viewBox is the text's own
* bounds, so it always runs edge to edge). An SVG filter pushes it through a
* turbulence field, and that field rises: the filtered group is moved up
* every frame while the text inside is moved back down, so the text stays
* put and only the air moves through it. The pointer turns the heat up.
* Reduced motion: crisp, still type.
*/
export function HeatHazeHeading({ text = "Ardent", as: Tag = "h2", ground = "black", intensity = 0.5, width = 72, className = "", style }: HeatHazeHeadingProps) {
const id = useId().replace(/:/g, "");
const svgRef = useRef<SVGSVGElement>(null);
const textRef = useRef<SVGTextElement>(null);
const gRef = useRef<SVGGElement>(null);
const filterRef = useRef<SVGFilterElement>(null);
const mapRef = useRef<SVGFEDisplacementMapElement>(null);
// A first guess at the text's bounds (cap height ~145 of 200, ~0.62em per letter at wdth 100), replaced once measured.
const [box, setBox] = useState<[number, number, number, number]>(() => [0, -146, Math.max(1, text.length) * 124 * (hvClamp(width, 62, 125) / 100), 148]);
const boxRef = useRef(box);
useEffect(() => {
boxRef.current = box;
}, [box]);
const reduced = useHvReduced();
const live = useRef({ intensity, heat: 0, ptr: 0 });
useEffect(() => {
live.current.intensity = intensity;
});
// Fit the viewBox to the text's own bounds, again once the face has loaded.
useLayoutEffect(() => {
const t = textRef.current;
if (!t) return;
let alive = true;
const fit = () => {
if (!alive) return;
try {
const b = t.getBBox();
if (b.width > 0) setBox([b.x, b.y, b.width, b.height]);
} catch {
// not rendered yet
}
};
fit();
document.fonts?.ready.then(fit);
return () => {
alive = false;
};
}, [text, width]);
useEffect(() => {
const svg = svgRef.current;
const g = gRef.current;
const t = textRef.current;
const f = filterRef.current;
const map = mapRef.current;
if (!svg || !g || !t || !f || !map) return;
if (reduced) {
map.setAttribute("scale", "0");
g.removeAttribute("transform");
t.removeAttribute("transform");
return;
}
let raf = 0;
let on = false;
let last = 0;
let rise = 0;
const L = live.current;
const frame = (now: number) => {
raf = 0;
if (!on) return;
if (last && now - last < 30) {
raf = requestAnimationFrame(frame);
return;
}
const dt = last ? Math.min(0.06, (now - last) / 1000) : 0;
last = now;
const [, by, , bh] = boxRef.current;
// The air rises a little under one line height a second.
rise += dt * bh * 0.55;
L.heat += (L.ptr - L.heat) * Math.min(1, dt * 4);
g.setAttribute("transform", "translate(0 " + (-rise).toFixed(2) + ")");
t.setAttribute("transform", "translate(0 " + rise.toFixed(2) + ")");
f.setAttribute("y", (by - bh * 0.3 + rise).toFixed(2));
map.setAttribute("scale", (bh * (0.01 * hvClamp(L.intensity * 4) + 0.032 * hvClamp(L.intensity) + 0.06 * L.heat)).toFixed(2));
raf = requestAnimationFrame(frame);
};
const io = new IntersectionObserver((es) => {
on = !!es[es.length - 1]?.isIntersecting;
last = 0;
if (on && !raf) raf = requestAnimationFrame(frame);
});
io.observe(svg);
const move = (e: PointerEvent) => {
const r = svg.getBoundingClientRect();
const dx = (e.clientX - hvClamp(e.clientX, r.left, r.right)) / Math.max(1, r.height);
const dy = (e.clientY - hvClamp(e.clientY, r.top, r.bottom)) / Math.max(1, r.height);
L.ptr = Math.exp(-(dx * dx + dy * dy) * 3);
};
const leave = () => {
L.ptr = 0;
};
window.addEventListener("pointermove", move, { passive: true });
document.documentElement.addEventListener("pointerleave", leave);
return () => {
on = false;
if (raf) cancelAnimationFrame(raf);
io.disconnect();
window.removeEventListener("pointermove", move);
document.documentElement.removeEventListener("pointerleave", leave);
};
}, [reduced]);
const [bx, by, bw, bh] = box;
const fill = ground === "black" ? HV_ORANGE : HV_BLACK;
const base = ground === "black" ? "#FF9A4D" : "#3A1200";
return (
<Tag className={"hv-scope m-0 w-full " + className} style={{ fontFamily: HV_DISPLAY, ...style }}>
<span className="sr-only">{text}</span>
<svg ref={svgRef} aria-hidden viewBox={bx + " " + (by - bh * 0.06) + " " + bw + " " + bh * 1.12} className="block h-auto w-full overflow-visible" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient id={id + "g"} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={fill} />
<stop offset="0.84" stopColor={fill} />
<stop offset="1" stopColor={base} />
</linearGradient>
<filter ref={filterRef} id={id + "f"} filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" x={bx - bh * 0.3} y={by - bh * 0.3} width={bw + bh * 0.6} height={bh * 1.6} colorInterpolationFilters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency={(1.4 / bh).toFixed(5) + " " + (5.2 / bh).toFixed(5)} numOctaves={2} seed={7} result="air" />
<feDisplacementMap ref={mapRef} in="SourceGraphic" in2="air" scale={reduced ? 0 : bh * 0.06} xChannelSelector="R" yChannelSelector="G" />
</filter>
</defs>
<g ref={gRef} filter={"url(#" + id + "f)"}>
<text
ref={textRef}
x="0"
y="0"
fill={"url(#" + id + "g)"}
style={{ fontFamily: HV_DISPLAY, fontWeight: 900, fontSize: 200, letterSpacing: "-0.02em", textTransform: "uppercase", fontVariationSettings: '"wdth" ' + hvClamp(width, 62, 125) }}
>
{text}
</text>
</g>
</svg>
</Tag>
);
}
export interface ArdentFooterProps {
brand?: string;
call?: string;
cta?: { label: string; href: string };
columns?: { title: string; links: { label: string; href: string }[] }[];
note?: string;
className?: string;
style?: CSSProperties;
}
/**
* ArdentFooter — black ground, the brand set edge to edge in heat haze.
*/
export function ArdentFooter({
brand = "Ardent",
call = "Bring your stage. We'll light it.",
cta = { label: "Book a test fire", href: "#book" },
columns = [
{ title: "Engine", links: [{ label: "R-9 overview", href: "#top" }, { label: "Rated figures", href: "#specs" }, { label: "Exploded view", href: "#engine" }] },
{ title: "Launch", links: [{ label: "Manifest", href: "#manifest" }, { label: "Rideshare", href: "#book" }, { label: "Dedicated", href: "#book" }] },
{ title: "Contact", links: [{ label: "Request a hot fire", href: "#request" }, { label: "launch@ardent.example", href: "mailto:launch@ardent.example" }] },
],
note = "© 2026 Ardent Propulsion. Figures are rated, not promised.",
className = "",
style,
}: ArdentFooterProps) {
return (
<footer data-hv-ground="black" className={"hv-scope @container relative w-full overflow-hidden " + className} style={{ background: HV_BLACK, color: HV_BONE, fontFamily: HV_DISPLAY, ...style }}>
<style>{HV_CSS}</style>
<div className="mx-auto w-full px-5 pt-20 @3xl:px-[4cqw] @3xl:pt-28">
<div className="grid grid-cols-1 gap-12 @3xl:grid-cols-[minmax(0,1.3fr)_minmax(0,2fr)]">
<div className="flex flex-col items-start gap-6">
<p className="m-0 text-[clamp(34px,9cqw,44px)] uppercase leading-[0.9] @3xl:text-[clamp(40px,3.8cqw,64px)]" style={{ fontWeight: 900, fontVariationSettings: '"wdth" 72' }}>
{call}
</p>
<a href={hvSafeHref(cta.href)} onClick={hvGo} className="hv-press inline-flex h-12 items-center gap-2.5 px-6 text-[15px] font-bold uppercase tracking-[0.04em] no-underline" style={{ background: HV_ORANGE, color: HV_BLACK }}>
{cta.label}
<HvArrow />
</a>
</div>
<div className="grid grid-cols-2 gap-8 @3xl:grid-cols-3">
{columns.map((c) => (
<nav key={c.title} aria-label={c.title} className="flex flex-col gap-3">
<p className="m-0 text-[11px] uppercase tracking-[0.16em]" style={{ fontFamily: HV_MONO, opacity: 0.55 }}>
{c.title}
</p>
<ul className="m-0 flex list-none flex-col gap-2 p-0">
{c.links.map((l) => (
<li key={l.label}>
<a href={hvSafeHref(l.href)} onClick={hvGo} className="text-[15px] no-underline decoration-2 underline-offset-4 hover:underline" style={{ color: "inherit", fontWeight: 500, textDecorationColor: HV_ORANGE }}>
{l.label}
</a>
</li>
))}
</ul>
</nav>
))}
</div>
</div>
<div className="mt-16 flex items-center justify-between gap-4 py-5 text-[11px] uppercase tracking-[0.14em] @3xl:mt-24" style={{ fontFamily: HV_MONO, boxShadow: "inset 0 1px 0 rgba(239,235,228,.2)", opacity: 0.7 }}>
<span>{note}</span>
<a href="#top" onClick={hvGo} className="no-underline hover:underline" style={{ color: "inherit" }}>
Back to top ↑
</a>
</div>
</div>
<div className="px-3 pb-2 @3xl:px-[1.5cqw]">
<HeatHazeHeading text={brand} as="p" ground="black" intensity={0.45} width={104} />
</div>
</footer>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this section
The last screen of the Hi-Vis kit. A closing line in heavy condensed type and one orange button, three columns of links, a fine print line with a way back to the top, and across the very bottom the brand's name set edge to edge in safety orange, shimmering in a rising heat haze that gets stronger as the pointer comes near.
Curator’s note
Free; carries the Heat Haze Heading. Link columns are labelled nav landmarks and every href goes through the kit's safe-link helper. 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.
