Mark Field
I sample about 2600 points along the nine paths of the Align Scale mark with getPointAtLength, in proportion to each path's length. Every point starts on a ray out from the shared centre and travels in to its place. A 7 second loop breathes the cloud, and the pointer's horizontal position scrubs it directly.
- 2026
- Canvas
- Particles
- Logo
- Pointer
Controls
Speed
Points
Scatter
Point size
Origin
The mark is Align Scale's, the studio I co-founded.
Source
piece.tsx306 lines
"use client";
import { useEffect, useRef } from "react";
import type { DomPieceProps } from "@/entries/types";
import { onThemeChange, readThemeColors, type RGB } from "@/lib/theme-colors";
import { CENTER, MARK_PATHS, VIEWBOX } from "./paths";
/**
* The Align Scale mark condensing out of a cloud of points. Canvas 2D, no library: moving a few
* thousand squares needs no scene graph. From clients/alignscale/alignscale-web, where scroll
* drove it. The stage does not scroll, so a 7 second loop drives it here and the pointer scrubs it.
*
* The points are the real mark. They are sampled off the logo's own paths with getPointAtLength,
* in proportion to each path's length, so the density is even. Each point starts on a ray out from
* the centre the four quadrants share and travels in: scale from the centre, per point.
*/
/** Seconds per loop at speed 1. */
const PERIOD = 7;
/** The loop in fractions of the period: condense, hold so the mark can be read, scatter, rest. */
const IN_END = 0.42;
const HOLD_END = 0.64;
const OUT_END = 0.94;
/** The caption pill and the pause button cover this much of the stage's bottom edge. */
const RESERVED = 76;
/** Mark size as a fraction of the stage's shorter side. */
const MARK = 0.46;
/** Values per point: target x, target y, reach, jitter x, jitter y, delay. */
const STRIDE = 6;
const SVG_NS = "http://www.w3.org/2000/svg";
interface Settings {
speed: number;
scatter: number;
size: number;
paused: boolean;
}
const num = (value: unknown, fallback: number) => (typeof value === "number" && Number.isFinite(value) ? value : fallback);
const clamp01 = (t: number) => Math.min(1, Math.max(0, t));
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
/** Per point: fast out of the cloud, slow into place. */
const settle = (t: number) => 1 - Math.pow(1 - t, 4.2);
const css = ([r, g, b]: RGB) => `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)})`;
function loopProgress(u: number): number {
if (u < IN_END) return easeInOut(u / IN_END);
if (u < HOLD_END) return 1;
if (u < OUT_END) return 1 - easeInOut((u - HOLD_END) / (OUT_END - HOLD_END));
return 0;
}
/**
* The mark's outline as one closed polyline per path, traced once per page load.
*
* getPointAtLength is slow: asking it for 2600 points in one go blocked the main thread for 115 to
* 160 ms every time the piece came on stage (measured in Chrome on the real stage). So the paths
* are traced once, a slice per frame, into polylines fine enough to be the curve, and every point
* count after that is interpolated from them with plain arithmetic.
*/
interface Outline {
/** x, y pairs. Every path in the mark is closed, so the last point joins the first. */
polylines: Float32Array[];
lengths: number[];
total: number;
}
const TRACE_SAMPLES = 1800;
const TRACE_PER_FRAME = 220;
let outline: Outline | null = null;
let tracing: Promise<Outline> | null = null;
/** Browser only: getPointAtLength needs a path element, and Safari wants it in the document. */
function traceOutline(): Promise<Outline> {
if (outline) return Promise.resolve(outline);
tracing ??= new Promise<Outline>((resolve) => {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", `0 0 ${VIEWBOX} ${VIEWBOX}`);
svg.style.cssText = "position:absolute;width:0;height:0;overflow:hidden";
document.body.appendChild(svg);
const paths = MARK_PATHS.map((d) => {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
svg.appendChild(path);
return path;
});
const lengths = paths.map((path) => path.getTotalLength());
const total = lengths.reduce((a, b) => a + b, 0);
const polylines = lengths.map((length) => new Float32Array(Math.max(16, Math.round((length / total) * TRACE_SAMPLES)) * 2));
let p = 0;
let k = 0;
const slice = () => {
for (let budget = TRACE_PER_FRAME; budget > 0 && p < paths.length; budget--) {
const n = polylines[p].length / 2;
const at = paths[p].getPointAtLength((k / n) * lengths[p]);
polylines[p][k * 2] = at.x;
polylines[p][k * 2 + 1] = at.y;
if (++k === n) {
k = 0;
p++;
}
}
if (p < paths.length) return requestAnimationFrame(slice);
svg.remove();
outline = { polylines, lengths, total };
resolve(outline);
};
slice();
});
return tracing;
}
/** `count` points spread along the outline by length, each with the randoms that scatter it. */
function samplePoints({ polylines, lengths, total }: Outline, count: number): Float32Array {
const data: number[] = [];
polylines.forEach((line, i) => {
const samples = line.length / 2;
const n = Math.max(8, Math.round((lengths[i] / total) * count));
for (let k = 0; k < n; k++) {
const at = (k / n) * samples;
const a = Math.floor(at) % samples;
const b = (a + 1) % samples;
const f = at - Math.floor(at);
const x = line[a * 2] + (line[b * 2] - line[a * 2]) * f;
const y = line[a * 2 + 1] + (line[b * 2 + 1] - line[a * 2 + 1]) * f;
// Reach and jitter are stored as unit randoms; `scatter` scales them at draw time, so that control never re-samples.
data.push(x, y, Math.random(), (Math.random() - 0.5) * 15, (Math.random() - 0.5) * 15, Math.random() * 0.45);
}
});
return new Float32Array(data);
}
export default function MarkField({ values, paused }: DomPieceProps) {
const hostRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const speed = Math.max(0, num(values.speed, 1));
const scatter = num(values.scatter, 1.6);
const size = num(values.size, 1.2);
const count = Math.round(Math.min(4000, Math.max(600, num(values.points, 2600))));
// The loop reads these every frame. Only `count` rebuilds it; the rest must not restart the animation.
const settings = useRef<Settings>({ speed, scatter, size, paused });
// Redraws one frame and restarts the loop if it may run. Set by the loop effect below.
const wake = useRef<() => void>(() => {});
// Position in the loop, 0..1. Kept outside the loop effect so re-sampling does not rewind it. -1: not started.
const phase = useRef(-1);
useEffect(() => {
settings.current = { speed, scatter, size, paused };
wake.current();
}, [speed, scatter, size, paused]);
useEffect(() => {
const host = hostRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!host || !canvas || !ctx) return;
// Empty until the outline is traced (a few frames on the first visit, none after).
let points: Float32Array = new Float32Array(0);
let gone = false;
void traceOutline().then((traced) => {
if (gone) return;
points = samplePoints(traced, count);
draw();
});
let ink = css(readThemeColors().ink);
// With reduced motion the stage mounts paused: start in the hold, so the still frame is the mark and not the cloud.
if (phase.current < 0) phase.current = settings.current.paused ? (IN_END + HOLD_END) / 2 : 0;
let width = 0;
let height = 0;
let dpr = 1;
let raf = 0;
let last = 0;
let visible = true;
let hover = false;
let pointerTarget = 0;
let pointer = 0;
// 0: the loop drives. 1: the pointer drives. Eased, so entering and leaving never jump.
let mix = 0;
let progress = loopProgress(phase.current);
const draw = () => {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (width === 0 || height === 0) return;
const free = Math.max(1, height - RESERVED);
const mark = Math.min(MARK * Math.min(width, height), free * 0.9);
const scale = (mark / VIEWBOX) * dpr;
const cx = (width / 2) * dpr;
const cy = (free / 2) * dpr;
const { scatter: reach, size: side } = settings.current;
const s = side * dpr;
const half = s / 2;
ctx.fillStyle = ink;
for (let i = 0; i < points.length; i += STRIDE) {
const tx = points[i] - CENTER;
const ty = points[i + 1] - CENTER;
const delay = points[i + 5];
const e = settle(clamp01((progress - delay) / (1 - delay)));
// Scattered position: the same ray from the centre, 1 to 2.5 times `scatter` further out, plus jitter.
const far = reach * (1 + points[i + 2] * 1.5);
const ox = tx * far + points[i + 3] * reach;
const oy = ty * far + points[i + 4] * reach;
ctx.globalAlpha = 0.14 + 0.8 * e;
ctx.fillRect(cx + (ox + (tx - ox) * e) * scale - half, cy + (oy + (ty - oy) * e) * scale - half, s, s);
}
ctx.globalAlpha = 1;
};
const tick = (now: number) => {
raf = 0;
const live = settings.current;
if (live.paused || !visible) return;
// Clamped: a frame after a hidden tab or a long task must not skip the hold.
const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
phase.current = (phase.current + (dt * live.speed) / PERIOD) % 1;
mix += ((hover ? 1 : 0) - mix) * (1 - Math.exp(-dt / 0.25));
pointer += (pointerTarget - pointer) * (1 - Math.exp(-dt / 0.1));
progress = loopProgress(phase.current) * (1 - mix) + pointer * mix;
draw();
raf = requestAnimationFrame(tick);
};
const start = () => {
if (raf || settings.current.paused || !visible) return;
last = 0;
raf = requestAnimationFrame(tick);
};
wake.current = () => {
draw();
start();
};
const resize = () => {
dpr = Math.min(window.devicePixelRatio || 1, 2);
width = host.clientWidth;
height = host.clientHeight;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
// Setting the size clears the canvas, so draw at once, also while paused.
draw();
};
const sizes = new ResizeObserver(resize);
sizes.observe(host);
const sight = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
start();
});
sight.observe(host);
// Left is scattered, right is condensed. The outer 10% on each side is slack so both ends can be reached.
const onMove = (event: PointerEvent) => {
if (settings.current.paused) return;
const rect = host.getBoundingClientRect();
pointerTarget = clamp01(((event.clientX - rect.left) / Math.max(1, rect.width) - 0.1) / 0.8);
// On a fresh entry the scrub starts from what is on screen, not from the last visit's position.
if (!hover && mix < 0.01) pointer = progress;
hover = true;
};
const onLeave = () => {
hover = false;
};
host.addEventListener("pointermove", onMove);
host.addEventListener("pointerleave", onLeave);
host.addEventListener("pointercancel", onLeave);
// A paused piece still has to change colour with the theme: one frame, no loop.
const stopTheme = onThemeChange(() => {
ink = css(readThemeColors().ink);
draw();
});
resize();
start();
return () => {
gone = true;
wake.current = () => {};
if (raf) cancelAnimationFrame(raf);
sizes.disconnect();
sight.disconnect();
stopTheme();
host.removeEventListener("pointermove", onMove);
host.removeEventListener("pointerleave", onLeave);
host.removeEventListener("pointercancel", onLeave);
};
}, [count]);
return (
<div ref={hostRef} aria-hidden="true" className="h-full w-full">
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
);
}paths.ts24 lines
/**
* The Align Scale mark as nine closed paths in a 126 unit viewBox. The piece samples points off
* these strings, so the cloud is the real geometry and not a traced copy.
* Order follows the quadrants: three quarter arcs, two triangles, two circles, the two quarter discs.
*/
export const VIEWBOX = 126;
/** The point all four quadrants share. The mark scales from here, so the points scatter from here. */
export const CENTER = 63.16;
export const MARK_PATHS: readonly string[] = [
"M63.1626 63.1625V1.16248C46.7192 1.16248 30.9492 7.6946 19.322 19.3219C7.69472 30.9491 1.1626 46.7191 1.1626 63.1625H63.1626Z",
"M63.1626 63.1625V20.5375C51.8577 20.5375 41.0159 25.0283 33.0222 33.022C25.0284 41.0158 20.5376 51.8576 20.5376 63.1625H63.1626Z",
"M63.1626 63.1625V39.9125C56.9963 39.9125 51.0826 42.362 46.7224 46.7222C42.3621 51.0825 39.9126 56.9962 39.9126 63.1625H63.1626Z",
"M63.1626 63.1625L94.1626 1.16248L125.163 63.1625H63.1626Z",
"M63.1626 63.1625L94.1626 30.6125L125.163 63.1625H63.1626Z",
"M32.1626 125.162C49.2834 125.162 63.1626 111.283 63.1626 94.1625C63.1626 77.0416 49.2834 63.1625 32.1626 63.1625C15.0418 63.1625 1.1626 77.0416 1.1626 94.1625C1.1626 111.283 15.0418 125.162 32.1626 125.162Z",
"M32.1625 111.212C41.579 111.212 49.2125 103.579 49.2125 94.1624C49.2125 84.746 41.579 77.1124 32.1625 77.1124C22.7461 77.1124 15.1125 84.746 15.1125 94.1624C15.1125 103.579 22.7461 111.212 32.1625 111.212Z",
"M125.163 63.1625C125.163 79.6059 118.63 95.3758 107.003 107.003C95.376 118.63 79.606 125.162 63.1626 125.162V63.1625H125.163Z",
"M63.1626 125.162C63.1626 108.719 69.6947 92.9491 81.322 81.3219C92.9492 69.6946 108.719 63.1625 125.163 63.1625V125.162H63.1626Z",
];