Skip to content

Heat Haze Heading

A line of type set edge to edge and seen through rising heat: an SVG turbulence field moves up through the letters, and the pointer turns the heat up.

DOM + CSS0 deps1 file · 8 KB TSXreduced-motion aware

FreeHi-Viseditorial
Preview

Props

45
104
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";

const HV_BLACK = "#0B0A09";

/**
 * 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';

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);
}

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>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this text effect

The air above a runway, an engine or a hot road bends whatever is behind it. This heading sits in that air. The word is fitted edge to edge across its box in heavy type, with a warm glow along its foot, and an SVG turbulence field rises through it all the time, so the letters shimmer and ripple upward without ever moving. Bring the pointer close and the heat turns up. It is real text for screen readers and search, it works at any width, and reduced motion leaves it crisp and still. Orange on black or black on orange.

Curator’s note

Free. The rising heat is a trick of coordinate spaces: the filtered group moves up every frame while the text inside it moves back down, so the turbulence (computed in the group's own space) flows up through type that stays put. The viewBox is the text's own bounds, so it always runs edge to edge. 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

Glass Etch Text

text effects