Skip to content

Spindrift Footer

A footer that sets the name edge to edge and lets the wind take sand off the top of every letter, the way spindrift streams off a dune crest.

Canvas 2D0 deps1 file · 25 KB TSXreduced-motion aware

FreeAeolianeditorial
Preview

Props

No runtime dependencies
"use client";

import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";

/**
 * SPINDRIFT FOOTER.
 * A footer that sets the name edge to edge and lets the wind take sand off the
 * top of every letter, the way spindrift streams off a dune crest.
 *
 * 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";

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

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

/** 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}}";

/** 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() });

/**
 * 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];
}

interface AeRaster {
  canvas: HTMLCanvasElement;
  /** The element's layout size in CSS pixels (unscaled by any transform above it). */
  w: number;
  h: number;
  /** Resolves once inline SVGs (decoded as images) are painted in too. */
  ready: Promise<void>;
}

const AE_STRETCH: Record<string, string> = {
  "50%": "ultra-condensed",
  "62.5%": "extra-condensed",
  "75%": "condensed",
  "87.5%": "semi-condensed",
  "100%": "normal",
  "112.5%": "semi-expanded",
  "125%": "expanded",
  "150%": "extra-expanded",
  "200%": "ultra-expanded",
};

function aeAlpha(color: string): number {
  if (!color || color === "transparent") return 0;
  const m = color.match(/rgba?\(([^)]+)\)/);
  if (!m || !m[1]) return 1;
  const parts = m[1].split(/[\s,/]+/).filter(Boolean);
  return parts.length > 3 ? parseFloat(parts[3] ?? "1") : 1;
}

function aeRadius(v: string, w: number, h: number): number {
  const n = parseFloat(v) || 0;
  return v.trim().endsWith("%") ? (n / 100) * Math.min(w, h) : n;
}

function aeRoundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: [number, number, number, number]) {
  const m = Math.min(w, h) / 2;
  const [a, b, c, d] = r.map((v) => Math.max(0, Math.min(v, m))) as [number, number, number, number];
  ctx.beginPath();
  ctx.moveTo(x + a, y);
  ctx.lineTo(x + w - b, y);
  ctx.arcTo(x + w, y, x + w, y + b, b);
  ctx.lineTo(x + w, y + h - c);
  ctx.arcTo(x + w, y + h, x + w - c, y + h, c);
  ctx.lineTo(x + d, y + h);
  ctx.arcTo(x, y + h, x, y + h - d, d);
  ctx.lineTo(x, y + a);
  ctx.arcTo(x, y, x + a, y, a);
  ctx.closePath();
}

/**
 * Paint `el` into a canvas at `res` pixels per CSS pixel: background colours,
 * borders and radii, overflow clips, child canvases, inline SVGs, input
 * values, and text, one character at a time at the exact place the browser
 * laid it out (read with Range rects), in the element's own font. That is
 * enough for anything built from flat fills and type, which is what every
 * Aeolian surface is. Everything is read synchronously at the call, so the
 * snapshot is of the element as it is now even if React redraws it a moment
 * later. The element's own opacity is ignored (a hidden element can still be
 * painted, ready to be built out of sand); nodes marked data-ae-skip are left out.
 */
function aeRasterize(el: HTMLElement, res: number): AeRaster | null {
  const R = el.getBoundingClientRect();
  const w = el.offsetWidth;
  const h = el.offsetHeight;
  if (!w || !h || !R.width) return null;
  const s = R.width / w;
  const canvas = document.createElement("canvas");
  canvas.width = Math.max(1, Math.ceil(w * res));
  canvas.height = Math.max(1, Math.ceil(h * res));
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;
  ctx.scale(res, res);
  const X = (x: number) => (x - R.left) / s;
  const Y = (y: number) => (y - R.top) / s;
  const range = document.createRange();
  const svgs: Promise<void>[] = [];

  const text = (t: Text, cs: CSSStyleDeclaration, alpha: number) => {
    const data = t.data;
    if (!data.trim()) return;
    ctx.globalAlpha = alpha * aeAlpha(cs.color);
    ctx.fillStyle = cs.color;
    ctx.font = cs.fontStyle + " " + cs.fontWeight + " " + cs.fontSize + " " + cs.fontFamily;
    const c2 = ctx as unknown as { fontStretch?: string; letterSpacing?: string };
    if ("fontStretch" in c2) c2.fontStretch = AE_STRETCH[cs.fontStretch] ?? "normal";
    if ("letterSpacing" in c2) c2.letterSpacing = "0px";
    ctx.textBaseline = "alphabetic";
    const m = ctx.measureText("Hg");
    const asc = m.fontBoundingBoxAscent || parseFloat(cs.fontSize) * 0.8;
    const desc = m.fontBoundingBoxDescent || parseFloat(cs.fontSize) * 0.2;
    const upper = cs.textTransform === "uppercase";
    for (let i = 0; i < data.length; i++) {
      const code = data.charCodeAt(i);
      const n = code >= 0xd800 && code <= 0xdbff ? 2 : 1;
      let ch = data.slice(i, i + n);
      if (/\s/.test(ch)) continue;
      range.setStart(t, i);
      range.setEnd(t, i + n);
      i += n - 1;
      const rect = range.getClientRects()[0];
      if (!rect || !rect.width) continue;
      if (upper) ch = ch.toUpperCase();
      const rh = rect.height / s;
      ctx.fillText(ch, X(rect.left), Y(rect.top) + (rh - (asc + desc)) / 2 + asc);
    }
  };

  const paint = (node: Element, alpha: number, root: boolean) => {
    if (!root && node.hasAttribute("data-ae-skip")) return;
    const cs = getComputedStyle(node);
    if (cs.display === "none" || (!root && cs.visibility === "hidden")) return;
    const a = root ? alpha : alpha * (parseFloat(cs.opacity) || 0);
    if (a < 0.01) return;
    const r = node.getBoundingClientRect();
    const x = X(r.left);
    const y = Y(r.top);
    const bw = r.width / s;
    const bh = r.height / s;
    const radii: [number, number, number, number] = [
      aeRadius(cs.borderTopLeftRadius, bw, bh),
      aeRadius(cs.borderTopRightRadius, bw, bh),
      aeRadius(cs.borderBottomRightRadius, bw, bh),
      aeRadius(cs.borderBottomLeftRadius, bw, bh),
    ];
    if (node instanceof SVGSVGElement) {
      const clone = node.cloneNode(true) as SVGSVGElement;
      clone.setAttribute("width", String(bw));
      clone.setAttribute("height", String(bh));
      clone.setAttribute("color", cs.color);
      clone.style.color = cs.color;
      const img = new Image();
      const src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(new XMLSerializer().serializeToString(clone));
      svgs.push(
        new Promise<void>((done) => {
          img.onload = () => {
            ctx.save();
            ctx.globalAlpha = a;
            ctx.drawImage(img, x, y, bw, bh);
            ctx.restore();
            done();
          };
          img.onerror = () => done();
          img.src = src;
        }),
      );
      return;
    }
    ctx.save();
    if (aeAlpha(cs.backgroundColor) > 0) {
      ctx.globalAlpha = a * aeAlpha(cs.backgroundColor);
      ctx.fillStyle = cs.backgroundColor;
      aeRoundRect(ctx, x, y, bw, bh, radii);
      ctx.fill();
    }
    const bt = parseFloat(cs.borderTopWidth) || 0;
    const br = parseFloat(cs.borderRightWidth) || 0;
    const bb = parseFloat(cs.borderBottomWidth) || 0;
    const bl = parseFloat(cs.borderLeftWidth) || 0;
    if (bt || br || bb || bl) {
      const uniform = bt === br && br === bb && bb === bl && cs.borderTopColor === cs.borderBottomColor && cs.borderLeftColor === cs.borderRightColor && cs.borderTopColor === cs.borderLeftColor;
      if (uniform && cs.borderTopStyle !== "none") {
        ctx.globalAlpha = a * aeAlpha(cs.borderTopColor);
        ctx.strokeStyle = cs.borderTopColor;
        ctx.lineWidth = bt;
        aeRoundRect(ctx, x + bt / 2, y + bt / 2, bw - bt, bh - bt, radii.map((v) => Math.max(0, v - bt / 2)) as [number, number, number, number]);
        ctx.stroke();
      } else {
        const side = (on: number, color: string, style: string, sx: number, sy: number, sw: number, sh: number) => {
          if (!on || style === "none" || aeAlpha(color) === 0) return;
          ctx.globalAlpha = a * aeAlpha(color);
          ctx.fillStyle = color;
          ctx.fillRect(sx, sy, sw, sh);
        };
        side(bt, cs.borderTopColor, cs.borderTopStyle, x, y, bw, bt);
        side(bb, cs.borderBottomColor, cs.borderBottomStyle, x, y + bh - bb, bw, bb);
        side(bl, cs.borderLeftColor, cs.borderLeftStyle, x, y, bl, bh);
        side(br, cs.borderRightColor, cs.borderRightStyle, x + bw - br, y, br, bh);
      }
    }
    if (node instanceof HTMLCanvasElement) {
      try {
        ctx.globalAlpha = a;
        ctx.drawImage(node, x, y, bw, bh);
      } catch {
        // A canvas we may not read (tainted): leave its box empty.
      }
    }
    if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement) {
      const isSelect = node instanceof HTMLSelectElement;
      // Secrets never reach the sand: passwords, one-time codes and card fields paint as dots.
      const secret = node instanceof HTMLInputElement && (node.type === "password" || /^(one-time-code|cc-)/.test(node.autocomplete));
      const value = isSelect ? (node.selectedOptions[0]?.text ?? "") : secret ? "•".repeat(node.value.length) : node.value;
      const shown = value || (isSelect ? "" : node.placeholder);
      if (shown && !(node instanceof HTMLInputElement && /^(checkbox|radio|range|color|file|hidden)$/.test(node.type))) {
        ctx.globalAlpha = a * (value ? 1 : 0.5);
        ctx.fillStyle = cs.color;
        ctx.font = cs.fontStyle + " " + cs.fontWeight + " " + cs.fontSize + " " + cs.fontFamily;
        ctx.textBaseline = "middle";
        const pl = (parseFloat(cs.paddingLeft) || 0) + bl;
        const lineY = node instanceof HTMLTextAreaElement ? y + (parseFloat(cs.paddingTop) || 0) + bt + (parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.3) / 2 : y + bh / 2;
        ctx.fillText(shown, x + pl, lineY, Math.max(1, bw - pl * 2));
      }
    }
    if (cs.overflow !== "visible" && cs.overflow !== "") {
      aeRoundRect(ctx, x, y, bw, bh, radii);
      ctx.clip();
    }
    for (const c of Array.from(node.childNodes)) {
      if (c.nodeType === 3) text(c as Text, cs, a);
      else if (c.nodeType === 1) paint(c as Element, a, false);
    }
    ctx.restore();
  };

  paint(el, 1, true);
  range.detach();
  return { canvas, w, h, ready: Promise.all(svgs).then(() => undefined) };
}

export interface SpindriftFooterProps {
  brand?: string;
  columns?: { title: string; links: { label: string; href: string }[] }[];
  note?: string;
  /** Grains in the air off the wordmark (a phone-width footer uses half). */
  grains?: number;
  className?: string;
  style?: CSSProperties;
}

/**
 * SpindriftFooter — the site's name set edge to edge, with sand streaming
 * off the top of every letter the way spindrift leaves a dune crest: grains
 * lift from the letters' upper edges and blow downwind with the page's
 * wind. Move the pointer through it and it gusts.
 */
export function SpindriftFooter({
  brand = "Barchan",
  columns = [
    { title: "Camp", links: [{ label: "The dune", href: "#camp" }, { label: "Forty years", href: "#camp" }] },
    { title: "Go", links: [{ label: "Routes", href: "#routes" }, { label: "Stays", href: "#stays" }] },
    { title: "Ask", links: [{ label: "Request a stay", href: "#request" }, { label: "Back to the top", href: "#top" }] },
  ],
  note = "A camp that moves with the dunes.",
  grains = 1100,
  className = "",
  style,
}: SpindriftFooterProps) {
  const markRef = useRef<HTMLDivElement>(null);
  const wordRef = useRef<HTMLSpanElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const live = useRef({ grains });
  useEffect(() => {
    live.current.grains = grains;
  });

  // Set the name edge to edge: measured at 100px, scaled to the box.
  useEffect(() => {
    const box = markRef.current;
    const word = wordRef.current;
    if (!box || !word) return;
    const fit = () => {
      word.style.fontSize = "100px";
      const w = word.getBoundingClientRect().width / (box.getBoundingClientRect().width / (box.clientWidth || 1));
      if (w > 0) word.style.fontSize = ((box.clientWidth / w) * 100 * 0.995).toFixed(2) + "px";
      box.dispatchEvent(new Event("ae-fit"));
    };
    fit();
    const ro = new ResizeObserver(fit);
    ro.observe(box);
    void document.fonts?.ready.then(fit);
    return () => ro.disconnect();
  }, [brand]);

  // The spindrift: emitters on the top edge of every letter, read from a snapshot of the word itself.
  useEffect(() => {
    const box = markRef.current;
    const word = wordRef.current;
    const canvas = canvasRef.current;
    if (!box || !word || !canvas) return;
    const c2 = canvas.getContext("2d");
    if (!c2) return;
    const ctx = c2;
    const still = aeReducedSnap();
    let emit: [number, number][] = [];
    let ox = 0;
    let oy = 0;
    type G = { x: number; y: number; px: number; py: number; vx: number; vy: number; life: number; lt: number; w: number };
    const pool: G[] = [];
    let raf = 0;
    let on = false;
    let last = 0;
    const gust = { x: -1e4, y: 0, vx: 0, vy: 0 };
    const scan = () => {
      const r = aeRasterize(word, 1);
      if (!r) return;
      const d = r.canvas.getContext("2d")?.getImageData(0, 0, r.canvas.width, r.canvas.height).data;
      if (!d) return;
      const W = r.canvas.width;
      const H = r.canvas.height;
      const out: [number, number][] = [];
      for (let x = 0; x < W; x += 2) {
        let prev = 0;
        for (let y = 0; y < H; y++) {
          const a = d[(y * W + x) * 4 + 3] ?? 0;
          // Every edge where sand faces the sky: a filled pixel with air above it.
          if (a > 120 && prev <= 120) out.push([x, y]);
          prev = a;
        }
      }
      emit = out;
      const wb = word.getBoundingClientRect();
      const cb = canvas.getBoundingClientRect();
      const s = cb.width / (canvas.clientWidth || 1);
      ox = (wb.left - cb.left) / s;
      oy = (wb.top - cb.top) / s;
    };
    const size = () => {
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      const W = Math.round(canvas.clientWidth * dpr);
      const H = Math.round(canvas.clientHeight * dpr);
      if (canvas.width !== W || canvas.height !== H) {
        canvas.width = W;
        canvas.height = H;
      }
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    const spawn = (g: G) => {
      const e = emit[Math.floor(Math.random() * emit.length)];
      if (!e) return;
      g.x = g.px = ox + e[0] + (Math.random() - 0.5) * 2;
      g.y = g.py = oy + e[1];
      const [wx, wy] = aeWindScreen();
      const s = 0.4 + aeWind.speed / 60;
      g.vx = wx * s * (20 + 40 * Math.random());
      g.vy = wy * s * 20 - (10 + 30 * Math.random()) * s;
      g.lt = 1.1 + Math.random() * 1.8;
      g.life = 1;
      g.w = Math.random();
    };
    const frame = (now: number) => {
      raf = 0;
      if (!on) return;
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
      last = now;
      size();
      const w = canvas.clientWidth;
      const h = canvas.clientHeight;
      ctx.clearRect(0, 0, w, h);
      const n = Math.round(live.current.grains * (w < 700 ? 0.5 : 1) * (0.25 + aeWind.speed / 40));
      while (pool.length < n) {
        const g: G = { x: 0, y: 0, px: 0, py: 0, vx: 0, vy: 0, life: Math.random(), lt: 1, w: 0 };
        spawn(g);
        g.life = Math.random();
        pool.push(g);
      }
      if (pool.length > n) pool.length = n;
      const [wx, wy] = aeWindScreen();
      const ws = 18 + aeWind.speed * 3.2;
      gust.vx *= Math.exp(-dt * 3);
      gust.vy *= Math.exp(-dt * 3);
      ctx.lineCap = "round";
      for (const g of pool) {
        g.life -= dt / g.lt;
        if (g.life <= 0) {
          spawn(g);
          continue;
        }
        const k = 1 - Math.exp(-dt * 2.2);
        const tx = wx * ws * (0.6 + g.w * 0.8) + Math.sin(now / 420 + g.w * 40) * 10;
        const ty = wy * ws * 0.6 - 8 + Math.cos(now / 530 + g.w * 31) * 8;
        g.vx += (tx - g.vx) * k;
        g.vy += (ty - g.vy) * k + 14 * dt * g.w;
        const dx = g.x - gust.x;
        const dy = g.y - gust.y;
        const f = Math.exp(-(dx * dx + dy * dy) / 9000);
        g.vx += gust.vx * f * dt * 6;
        g.vy += gust.vy * f * dt * 6;
        g.px = g.x;
        g.py = g.y;
        g.x += g.vx * dt;
        g.y += g.vy * dt;
        const a = Math.min(1, g.life * 2.4) * Math.min(1, (1 - g.life) * 8) * (0.35 + 0.5 * g.w);
        ctx.strokeStyle = "rgba(221,170,114," + a.toFixed(3) + ")";
        ctx.lineWidth = 0.7 + g.w * 0.9;
        ctx.beginPath();
        ctx.moveTo(g.px - (g.x - g.px) * 1.5, g.py - (g.y - g.py) * 1.5);
        ctx.lineTo(g.x, g.y);
        ctx.stroke();
      }
      raf = requestAnimationFrame(frame);
    };
    const begin = () => {
      scan();
      if (!still && on && !raf) raf = requestAnimationFrame(frame);
    };
    const io = new IntersectionObserver((es) => {
      on = !!es[es.length - 1]?.isIntersecting;
      last = 0;
      if (on) begin();
      else if (raf) {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    });
    io.observe(canvas);
    const refit = () => {
      pool.length = 0;
      scan();
    };
    box.addEventListener("ae-fit", refit);
    const move = (e: PointerEvent) => {
      const cb = canvas.getBoundingClientRect();
      const s = cb.width / (canvas.clientWidth || 1);
      const x = (e.clientX - cb.left) / s;
      const y = (e.clientY - cb.top) / s;
      if (gust.x > -1e3) {
        gust.vx += (x - gust.x) * 6;
        gust.vy += (y - gust.y) * 6;
      }
      gust.x = x;
      gust.y = y;
    };
    const leave = () => {
      gust.x = -1e4;
    };
    box.addEventListener("pointermove", move, { passive: true });
    box.addEventListener("pointerleave", leave);
    return () => {
      io.disconnect();
      if (raf) cancelAnimationFrame(raf);
      box.removeEventListener("ae-fit", refit);
      box.removeEventListener("pointermove", move);
      box.removeEventListener("pointerleave", leave);
    };
  }, []);

  return (
    <footer data-ae-ground="night" className={"ae-scope @container relative w-full overflow-hidden " + className} style={{ background: AE_NIGHT, color: AE_HAZE, fontFamily: AE_DISPLAY, ...style }}>
      <style>{AE_CSS}</style>
      <div className="grid grid-cols-2 gap-x-6 gap-y-10 px-5 pb-10 pt-20 @3xl:grid-cols-[1.4fr_repeat(3,1fr)] @3xl:px-[5.5cqw] @3xl:pt-[8cqw]">
        <div className="col-span-2 @3xl:col-span-1">
          <span style={{ color: AE_SAND }}>
            <AeMark size={28} />
          </span>
          <p className="m-0 mt-4 max-w-[22rem] text-[15px] leading-[1.6]" style={{ opacity: 0.72, fontStretch: "100%" }}>
            {note}
          </p>
        </div>
        {columns.map((c) => (
          <nav key={c.title} aria-label={c.title}>
            <p className="m-0 text-[11px] uppercase tracking-[0.16em]" style={{ fontFamily: AE_MONO, opacity: 0.55 }}>
              {c.title}
            </p>
            <ul className="m-0 mt-4 list-none p-0">
              {c.links.map((l) => (
                <li key={l.label} className="py-1.5">
                  <a href={aeSafeHref(l.href)} onClick={aeGo} className="text-[15px] no-underline hover:underline hover:underline-offset-4" style={{ color: "inherit", fontStretch: "100%" }}>
                    {l.label}
                  </a>
                </li>
              ))}
            </ul>
          </nav>
        ))}
      </div>
      <div ref={markRef} className="relative mx-5 pt-[9cqw] @3xl:mx-[5.5cqw]">
        <canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-x-[-6%] top-0 h-full w-[112%]" />
        <span ref={wordRef} className="relative inline-block whitespace-nowrap uppercase leading-[0.8]" style={{ fontSize: 100, fontStretch: "125%", fontWeight: 560, letterSpacing: "-0.01em", color: AE_SAND }}>
          {brand}
        </span>
      </div>
      <div className="flex flex-wrap items-center justify-between gap-3 px-5 pb-8 pt-6 text-[12px] @3xl:px-[5.5cqw]" style={{ fontFamily: AE_MONO, opacity: 0.55 }}>
        <span className="uppercase tracking-[0.14em]">© {brand}</span>
        <span className="uppercase tracking-[0.14em]">Wind and sand, rendered live</span>
      </div>
    </footer>
  );
}

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

Details

About this section

The close of the page. Link columns and a line about the place sit above the name, set wide and edge to edge in sand. The wind takes the letters' crests: grains lift off the top of every letter (and off every upward-facing edge inside them) and stream away downwind, more of them the harder it blows. Move the pointer through the name and it gusts.

Curator’s note

Free. The wordmark is fitted to the width (measured at 100px, rescaled on resize and when the font lands), then snapshotted once to find every edge where a letter faces the sky; grains are born on those edges and carried by the kit's wind as short streaks on a 2D canvas that only runs on screen. The pointer adds a gust. 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

Pro
Pro
NewPro