Skip to content

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

FreeHi-Viseditorial
Preview

Props

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

Pairs well with

Heat Haze Heading

text effects
Pro