Maze Footer
A large link footer: four columns of grouped links, a newsletter card and a follow card on the side, a big logo along the bottom, and a line of legal links under it. The logo is the interesting part. Point at it and it drops into a mosaic, then breaks apart into loose pixel blocks that wander around the row. Move away and the blocks fly home, click back into place, and sharpen into the vector logo again.
Move across the logo slowly, then leave it. The demo opens in the Blue brand colour, which fills the badges, the social card, and some of the scattered blocks. Mono shows the neutral default. Try Mobile and Tablet for the two- and four-column layouts, or Full screen to see it at the bottom of a real page.
Three phases
The effect runs in three phases, and each one reverses cleanly if the pointer changes its mind partway through.
- Pixelate. The vector logo steps down through three mosaic sizes (a third, two thirds, then a full block), about 55ms per step. Using a few hard steps instead of a smooth blur makes it look digital, and it is quick enough that the scatter still feels immediate.
- Scatter. Each block leaves for a random cell in the row. The start is delayed by the block's distance from the pointer, so the break-up ripples out from where you entered. Once a block lands it wanders one to three cells up, down, left, or right every few seconds, like a walk through a maze. Blocks near the pointer are pushed aside, so moving the pointer clears a path.
- Rebuild. When the pointer leaves, blocks head home in a sweep from left to right, so the logo rebuilds in reading order. The return spring is critically damped, so blocks glide into place without overshooting. Once every block is home, the pixels cross-fade into the vector logo over about 300ms. Stepping back down through the mosaic sizes would re-quantise the letter edges at each step and make them jitter. The two layers are added together rather than blended, so the logo never dims halfway through the fade.
On wide rows the logo covers about half the width and sits in the
centre. The canvas still spans the whole row, so the blocks spread into the
empty space on both sides. Its offset is rounded to whole blocks, so the
blocks at home and the scattered ones sit on the same grid. Pass
align="start" to pin it to the left instead.
Pixel size
A block is one viewBox unit of the logo by default, so the grid follows the mark's own proportions. Larger blocks give a chunkier mosaic and fewer, heavier pixels.
How it is drawn
- Coverage comes from the real path. The SVG path is filled once into an offscreen canvas. A summed-area table of that mask gives any cell's coverage in four reads, and a cell becomes a block when it is at least 40% covered. The same table builds all three mosaic sizes.
- Every block is a spring. Each block has a position, a velocity, and a target, which is either its home or its scatter cell. Switching targets never restarts anything, so hovering again mid-rebuild sends blocks straight back out from wherever they are.
- It sleeps at rest. The animation loop runs only while the pointer is over the logo or blocks are still moving. When the logo is whole it is a single vector fill and no frames are drawn.
- Blocks snap to device pixels, so the mosaic has no hairline seams on any screen density.
Details
- Colours come from
var(--foreground)through a hidden probe element and are read again when the theme changes. The optionalaccenttints about one block in seven, fading in with distance from home, so the colour shows only while the logo is broken apart. - On touch screens, a tap scatters the blocks and they rebuild after a moment.
- With reduced motion the logo stays a still vector and ignores the pointer.
- The canvas is
aria-hidden; the company name is repeated in ansr-onlyspan. - Pass any SVG path as
mark(with its viewBox width and height) to use a different logo.
Usage
The footer is a server component; only the logo ships JavaScript.
import {
InstagramLogoIcon,
LinkedinLogoIcon,
XLogoIcon,
YoutubeLogoIcon,
} from "@phosphor-icons/react/dist/ssr";
import { MAZE_MARK, PixelLogo, type LogoMark } from "@/components/blocks/footer/pixel-logo";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type FooterLink = { label: string; href: string; badge?: string };
type FooterGroup = { title: string; links: FooterLink[] };
type SocialLink = { label: string; href: string; icon: keyof typeof SOCIAL_ICONS };
const SOCIAL_ICONS = {
linkedin: LinkedinLogoIcon,
instagram: InstagramLogoIcon,
x: XLogoIcon,
youtube: YoutubeLogoIcon,
};
const links = (...labels: string[]): FooterLink[] =>
labels.map((label) => ({ label, href: "#" }));
/** Each inner array is one column; groups in it stack. Columns are kept to the
* same height: one group of eight, or two groups of three. */
const DEFAULT_COLUMNS: FooterGroup[][] = [
[
{
title: "Company",
links: links(
"About",
"Pricing",
"Journal",
"Careers",
"Customers",
"Partners",
"Press kit",
"Get in touch",
),
},
],
[
{
title: "Product",
links: [
{ label: "Flow Builder", href: "#" },
{ label: "Journey Maps", href: "#", badge: "New" },
...links("Automations", "Team Boards", "Insights", "Integrations", "Desktop App"),
{ label: "Public API", href: "#", badge: "Beta" },
],
},
],
[
{
title: "Learn",
links: links(
"Getting Started",
"Help Center",
"Guides & Playbooks",
"Live Sessions",
"Template Gallery",
"Community Forum",
"Changelog",
"Roadmap",
),
},
],
[
{ title: "Made For", links: links("Product Teams", "Design Teams", "Operations") },
{ title: "Stories", links: links("Northwind", "Lumen Labs", "Parcel & Co") },
],
];
const DEFAULT_SOCIALS: SocialLink[] = [
{ label: "LinkedIn", href: "#", icon: "linkedin" },
{ label: "Instagram", href: "#", icon: "instagram" },
{ label: "X", href: "#", icon: "x" },
{ label: "YouTube", href: "#", icon: "youtube" },
];
const DEFAULT_LEGAL: FooterLink[] = links(
"Privacy",
"Terms",
"Security",
"Accessibility",
"Cookie Preferences",
"System Status",
);
const focusRing = "outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
export function MazeFooter({
name = "Maze",
columns = DEFAULT_COLUMNS,
socials = DEFAULT_SOCIALS,
legal = DEFAULT_LEGAL,
newsletter = { title: "Field notes, once a month", action: "#" },
mark = MAZE_MARK,
accent,
accentForeground = "oklch(0.2 0 0)",
className,
}: {
name?: string;
columns?: FooterGroup[][];
socials?: SocialLink[];
legal?: FooterLink[];
/** Posts an `email` field to `action`. Pass null to drop the card. */
newsletter?: { title: string; action: string } | null;
/** The logo along the bottom, as an SVG path. */
mark?: LogoMark;
/** Fills the badges, the follow card, and some scattered logo blocks. */
accent?: string;
/** Text on accent surfaces. Keep it dark for light accents in both themes. */
accentForeground?: string;
className?: string;
}) {
const accentStyle = accent
? ({ "--footer-accent": accent, "--footer-accent-fg": accentForeground } as React.CSSProperties)
: undefined;
return (
<footer
style={accentStyle}
className={cn("@container bg-background text-foreground", className)}
>
<div className="px-5 pt-12 pb-6 @3xl:px-10 @3xl:pt-16 @3xl:pb-8">
<div className="grid gap-12 @5xl:grid-cols-[minmax(0,1fr)_18rem] @5xl:gap-10 @7xl:grid-cols-[minmax(0,1fr)_21rem]">
<nav
aria-label="Footer"
className="grid grid-cols-2 gap-x-3 gap-y-10 @3xl:grid-cols-4 @3xl:gap-x-6"
>
{columns.map((stack, i) => (
<div key={i} className="flex flex-col gap-10">
{stack.map((group) => (
<div key={group.title} className="flex flex-col items-start gap-2">
<h2 className="rounded-md bg-muted px-2 py-1.5 text-[11px] leading-none font-medium tracking-[0.04em] text-muted-foreground uppercase">
{group.title}
</h2>
<ul className="flex flex-col items-start gap-0.5">
{group.links.map((link) => (
<li key={link.label}>
<a
href={link.href}
className={cn(
"inline-flex min-h-8 items-center gap-2 rounded-lg px-2 py-1 text-[15px] leading-snug tracking-[-0.01em] text-pretty transition-colors duration-150 hover:bg-muted @3xl:text-base",
focusRing,
)}
>
{link.label}
{link.badge && (
<span
className={cn(
"shrink-0 rounded-[4px] px-1 text-xs leading-[1.4]",
accent
? "bg-(--footer-accent) text-(--footer-accent-fg)"
: "bg-foreground text-background",
)}
>
{link.badge}
</span>
)}
</a>
</li>
))}
</ul>
</div>
))}
</div>
))}
</nav>
<div className="grid items-start gap-2 @2xl:grid-cols-2 @5xl:grid-cols-1">
{newsletter && (
<div className="rounded-xl bg-card p-3 shadow-(--custom-shadow)">
<MazeArt className="rounded-md bg-muted" />
<h2 className="mt-4 mb-4 px-1 text-2xl font-light tracking-tight text-balance">
{newsletter.title}
</h2>
<form action={newsletter.action} method="post" className="flex gap-2">
<Input
type="email"
name="email"
required
autoComplete="email"
aria-label="Email address"
placeholder="you@company.com"
className="h-10 flex-1"
/>
<Button type="submit" className="h-10 px-4">
Join
</Button>
</form>
</div>
)}
<div
className={cn(
"flex flex-wrap items-center justify-between gap-x-4 gap-y-1 rounded-xl py-2 pr-2 pl-4",
accent ? "bg-(--footer-accent) text-(--footer-accent-fg)" : "bg-muted",
)}
>
<p className="text-xl font-light tracking-tight">Find us around</p>
<ul className="flex">
{socials.map((social) => {
const Icon = SOCIAL_ICONS[social.icon];
return (
<li key={social.label}>
<a
href={social.href}
aria-label={social.label}
className={cn(
"flex size-10 items-center justify-center rounded-lg transition-colors duration-150 hover:bg-black/5 dark:hover:bg-white/10",
accent && "hover:bg-(--footer-accent-fg)/15 dark:hover:bg-(--footer-accent-fg)/15",
focusRing,
)}
>
<Icon className="size-5" />
</a>
</li>
);
})}
</ul>
</div>
</div>
</div>
<PixelLogo mark={mark} label={name} accent={accent} className="mt-14 @3xl:mt-20" />
<div className="mt-8 flex flex-wrap items-center gap-x-4 gap-y-0.5 @3xl:mt-12">
<p className="text-xs text-muted-foreground tabular-nums">
© {new Date().getFullYear()} {name}. Made for teams finding their way.
</p>
<ul className="contents">
{legal.map((link) => (
<li key={link.label}>
<a
href={link.href}
className={cn(
"-mx-1 inline-flex h-8 items-center rounded-md px-1 text-[11px] tracking-[0.04em] text-muted-foreground uppercase transition-colors duration-150 hover:text-foreground",
focusRing,
)}
>
{link.label}
</a>
</li>
))}
</ul>
</div>
</div>
</footer>
);
}
const MAZE_COLS = 16;
const MAZE_ROWS = 11;
const MAZE_CELL = 10;
/** Carves a maze depth-first and returns its walls as one SVG path. The same
* seed always gives the same maze, so it renders on the server. */
function mazePath(seed: number) {
const cols = MAZE_COLS;
const rows = MAZE_ROWS;
const size = MAZE_CELL;
let a = seed;
const rand = () => {
a = (Math.imul(a, 1103515245) + 12345) & 0x7fffffff;
return a / 0x7fffffff;
};
// right[i] and down[i] say whether that wall of cell i still stands.
const right = new Array(cols * rows).fill(true);
const down = new Array(cols * rows).fill(true);
const seen = new Array(cols * rows).fill(false);
const stack = [0];
seen[0] = true;
while (stack.length) {
const i = stack[stack.length - 1];
const x = i % cols;
const y = Math.floor(i / cols);
const next = [
x < cols - 1 && i + 1,
x > 0 && i - 1,
y < rows - 1 && i + cols,
y > 0 && i - cols,
].filter((n): n is number => n !== false && !seen[n]);
if (!next.length) {
stack.pop();
continue;
}
const n = next[Math.floor(rand() * next.length)];
if (n === i + 1) right[i] = false;
else if (n === i - 1) right[n] = false;
else if (n === i + cols) down[i] = false;
else down[n] = false;
seen[n] = true;
stack.push(n);
}
// The outer wall leaves a gap at the top left and bottom right.
let d = `M${size} 0H${cols * size}V${rows * size}M${(cols - 1) * size} ${rows * size}H0V0`;
for (let i = 0; i < cols * rows; i++) {
const x = (i % cols) * size;
const y = Math.floor(i / cols) * size;
if (right[i] && i % cols < cols - 1) d += `M${x + size} ${y}v${size}`;
if (down[i] && Math.floor(i / cols) < rows - 1) d += `M${x} ${y + size}h${size}`;
}
return d;
}
/** A small generated maze for the newsletter card. */
function MazeArt({ seed = 7, className }: { seed?: number; className?: string }) {
return (
<div className={cn("aspect-[640/440] overflow-hidden p-5", className)}>
<svg
aria-hidden="true"
viewBox={`-1 -1 ${MAZE_COLS * MAZE_CELL + 2} ${MAZE_ROWS * MAZE_CELL + 2}`}
className="size-full text-foreground/25"
fill="none"
>
<path d={mazePath(seed)} stroke="currentColor" strokeWidth={1.25} strokeLinecap="square" />
<rect x={2.5} y={2.5} width={5} height={5} className="fill-foreground" />
</svg>
</div>
);
}"use client";
import { useReducedMotion } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
/** An SVG path and the viewBox it was drawn in (`x` is the viewBox min-x). */
export type LogoMark = { d: string; x?: number; width: number; height: number };
export const MAZE_MARK: LogoMark = {
x: 32.322,
width: 75,
height: 24,
d: "M57.187.433V23.26c0 .24-.196.434-.437.434h-2.858a.436.436 0 0 1-.437-.434V5.77c0-.202-.135-.337-.305-.337s-.271.1-.34.303l-5.97 17.662a.44.44 0 0 1-.415.296h-3.308a.44.44 0 0 1-.413-.295L36.698 5.736c-.068-.202-.17-.303-.34-.303-.169 0-.304.135-.304.337v17.49c0 .24-.196.434-.437.434h-2.858a.436.436 0 0 1-.437-.434V.432c0-.24.196-.435.437-.435h5.153c.187 0 .353.118.413.295L44.5 18.327c.068.203.17.304.34.304.169 0 .27-.101.338-.304L51.354.293a.44.44 0 0 1 .414-.295h4.983c.241 0 .437.195.437.435m17.235 11.852v10.974c0 .24-.195.435-.437.435h-2.62a.436.436 0 0 1-.437-.434V21.87c0-.236-.135-.37-.271-.37-.17 0-.237.067-.441.303-1.255 1.418-2.951 2.194-5.054 2.194-3.189 0-5.53-1.755-5.53-4.928 0-3.308 2.443-4.827 6.412-5.536l2.103-.371c1.865-.338 2.578-.71 2.578-1.688 0-1.62-1.255-2.566-3.358-2.566-1.86 0-3.252.759-3.466 2.356a.436.436 0 0 1-.433.379H60.68a.44.44 0 0 1-.434-.487c.365-3.324 3.172-5.015 7.122-5.015 4.24 0 7.055 1.856 7.055 6.143m-3.595 3.378a.435.435 0 0 0-.591-.407c-.451.177-1.061.307-1.784.438l-1.662.304c-2.51.473-3.426 1.384-3.426 2.903 0 1.384.95 2.329 2.68 2.329 2.476 0 4.783-1.62 4.783-4.557zM89.969 6.48H77.342a.436.436 0 0 0-.437.435v2.202c0 .24.196.435.437.435h7.433c.237 0 .339.135.339.304 0 .1-.068.202-.136.303l-8.236 10.873a.87.87 0 0 0-.176.524v1.704c0 .24.195.435.437.435h12.966a.436.436 0 0 0 .437-.434V21.09a.436.436 0 0 0-.437-.435h-7.874c-.237 0-.34-.135-.34-.303 0-.102.069-.203.136-.304L90.23 9.04a.87.87 0 0 0 .176-.524V6.914a.436.436 0 0 0-.437-.435m17.353 8.203c0 .323 0 .631-.011.934a.434.434 0 0 1-.436.416H95.942a.434.434 0 0 0-.437.461c.193 2.671 1.52 4.636 4.185 4.636 2.057 0 3.226-.998 3.56-2.155a.434.434 0 0 1 .418-.31h2.849c.273 0 .478.246.431.513-.572 3.233-3.516 4.821-7.258 4.821-4.817 0-8.04-3.14-8.04-8.911 0-5.705 3.053-8.945 8.074-8.945 4.817 0 7.598 3.004 7.598 8.54m-3.765-1.722c0-2.565-1.56-3.95-3.833-3.95-2.365 0-3.771 1.479-4.129 4.052a.327.327 0 0 0 .325.37h7.196c.237 0 .441-.134.441-.472",
};
type Block = {
hx: number; // home, inside the logo
hy: number;
sx: number; // scatter cell, somewhere in the row
sy: number;
x: number;
y: number;
vx: number;
vy: number;
at: number; // when this block switches to its next target
hop: number; // when it next wanders to a neighbouring cell
tint: boolean;
};
const STEP_IN = 0.055; // seconds per mosaic step on the way in
const FADE_OUT = 0.32; // seconds the pixels take to dissolve into the vector logo
const FADE_BACK = 0.12; // and to come back if the pointer returns mid-fade
const MOSAIC = [1 / 3, 2 / 3, 1]; // block sizes as fractions of a full cell
const SCATTER = { k: 120, c: 17 }; // a loose spring with a little overshoot
const RETURN = { k: 230, c: 30 }; // critically damped, so blocks land without a wobble
const WIDE = 960; // px of row width at which the logo stops filling the row
const MIN_CELL = 4;
function random(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Summed-area table of the mask's alpha, so any cell's coverage is four reads. */
function areaTable(alpha: Uint8ClampedArray, w: number, h: number) {
const table = new Float64Array((w + 1) * (h + 1));
for (let y = 0; y < h; y++) {
let row = 0;
for (let x = 0; x < w; x++) {
row += alpha[(y * w + x) * 4 + 3] / 255;
table[(y + 1) * (w + 1) + x + 1] = table[y * (w + 1) + x + 1] + row;
}
}
return (x0: number, y0: number, x1: number, y1: number) => {
x0 = Math.max(0, Math.min(w, Math.round(x0)));
x1 = Math.max(0, Math.min(w, Math.round(x1)));
y0 = Math.max(0, Math.min(h, Math.round(y0)));
y1 = Math.max(0, Math.min(h, Math.round(y1)));
const area = (x1 - x0) * (y1 - y0);
if (!area) return 0;
const at = (x: number, y: number) => table[y * (w + 1) + x];
return (at(x1, y1) - at(x0, y1) - at(x1, y0) + at(x0, y0)) / area;
};
}
/** Top-left corners of every cell of size `s` that is at least 40% covered. */
function cells(coverage: ReturnType<typeof areaTable>, w: number, h: number, s: number) {
const out: number[] = [];
for (let y = 0; y < h; y += s) {
for (let x = 0; x < w; x += s) {
if (coverage(x, y, x + s, y + s) >= 0.4) out.push(x, y);
}
}
return out;
}
export function PixelLogo({
mark = MAZE_MARK,
label = "Maze",
pixelSize = 1,
wideWidth = 0.48,
align = "center",
accent,
className,
}: {
mark?: LogoMark;
/** Read by screen readers in place of the canvas. */
label?: string;
/** Size of one block, in the mark's viewBox units. */
pixelSize?: number;
/** Share of the row the logo spans once the row is wider than 960px. Blocks
* still scatter across the whole row. */
wideWidth?: number;
/** Where the logo sits in the row when it does not fill it. */
align?: "center" | "start";
/** A colour some scattered blocks pick up. Unset keeps every block neutral. */
accent?: string;
className?: string;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const probeRef = useRef<HTMLSpanElement>(null);
const reduceMotion = useReducedMotion();
useEffect(() => {
const canvas = canvasRef.current;
const probe = probeRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !probe || !ctx) return;
const logo = new Path2D(mark.d);
const mask = document.createElement("canvas");
const maskCtx = mask.getContext("2d", { willReadFrequently: true });
if (!maskCtx) return;
const readColor = (value: string) => {
probe.style.color = value;
return getComputedStyle(probe).color;
};
let ink = readColor("var(--foreground)");
let tint = accent ? readColor(accent) : ink;
let width = 0;
let height = 0;
let logoWidth = 0;
let logoX = 0;
let scale = 1;
let cell = MIN_CELL;
let dpr = 1;
let cols = 1;
let rows = 1;
let mosaic: number[][] = [];
let blocks: Block[] = [];
let rand = random(1);
let hovered = false;
let level = 0; // 0 is the vector logo, 1–3 the mosaic steps
let fade = 0; // 0–1, how far the pixels have dissolved back into the vector
let frame = 0;
let last = 0;
let release = 0;
const pointer = { x: 0, y: 0, on: 0 };
const clock = () => performance.now() / 1000;
const layout = () => {
const w = canvas.clientWidth;
if (!w) return;
dpr = Math.min(window.devicePixelRatio || 1, 2);
width = w;
logoWidth = w >= WIDE ? w * wideWidth : w;
scale = logoWidth / mark.width;
height = Math.ceil(mark.height * scale);
cell = Math.max(MIN_CELL, pixelSize * scale);
cols = Math.max(1, Math.floor(width / cell));
rows = Math.max(1, Math.floor(height / cell));
// Snap the offset to whole cells so home and scatter share one grid.
logoX = align === "center" ? Math.round((width - logoWidth) / 2 / cell) * cell : 0;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
canvas.style.height = height + "px";
mask.width = Math.ceil(logoWidth);
mask.height = height;
maskCtx.setTransform(1, 0, 0, 1, 0, 0);
maskCtx.clearRect(0, 0, mask.width, mask.height);
maskCtx.setTransform(scale, 0, 0, scale, -(mark.x ?? 0) * scale, 0);
maskCtx.fill(logo);
const coverage = areaTable(
maskCtx.getImageData(0, 0, mask.width, mask.height).data,
mask.width,
mask.height,
);
mosaic = MOSAIC.map((f) =>
cells(coverage, mask.width, mask.height, cell * f).map((v, i) => (i % 2 ? v : v + logoX)),
);
rand = random(mosaic[2].length * 7919 + cols);
const home = mosaic[2];
blocks = [];
for (let i = 0; i < home.length; i += 2) {
blocks.push({
hx: home[i],
hy: home[i + 1],
sx: 0,
sy: 0,
x: home[i],
y: home[i + 1],
vx: 0,
vy: 0,
at: 0,
hop: 0,
tint: rand() < 0.14,
});
}
hovered = false;
level = 0;
fade = 0;
pointer.on = 0;
draw();
};
const rect = (x: number, y: number, s: number) => {
const x0 = Math.round(x * dpr);
const y0 = Math.round(y * dpr);
ctx.fillRect(x0, y0, Math.round((x + s) * dpr) - x0, Math.round((y + s) * dpr) - y0);
};
const vector = (alpha: number) => {
ctx.globalAlpha = alpha;
// Adding the two same-colour layers keeps overlaps fully opaque mid-fade,
// where normal blending would let the logo dim for a moment.
if (alpha < 1) ctx.globalCompositeOperation = "lighter";
ctx.fillStyle = ink;
ctx.setTransform(scale * dpr, 0, 0, scale * dpr, (logoX - (mark.x ?? 0) * scale) * dpr, 0);
ctx.fill(logo);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
};
const draw = () => {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = ink;
if (level <= 0) {
vector(1);
return;
}
// On the way out the pixels cross-fade into the vector instead of
// stepping back down, so the edges never re-quantise and jump.
const mix = fade * fade * (3 - 2 * fade);
ctx.globalAlpha = 1 - mix;
const step = Math.ceil(level);
if (step < 3) {
const list = mosaic[step - 1];
const s = cell * MOSAIC[step - 1];
for (let i = 0; i < list.length; i += 2) rect(list[i], list[i + 1], s);
ctx.globalAlpha = 1;
if (mix > 0) vector(mix);
return;
}
// Blocks near the pointer are pushed aside, so it clears a path.
const reach = cell * 7;
const place = (b: Block) => {
let x = b.x;
let y = b.y;
const out = Math.min(1, Math.hypot(b.x - b.hx, b.y - b.hy) / cell);
if (pointer.on > 0.01 && out > 0) {
const dx = x + cell / 2 - pointer.x;
const dy = y + cell / 2 - pointer.y;
const d = Math.hypot(dx, dy) || 1;
if (d < reach) {
const push = (1 - d / reach) ** 2 * cell * 3 * pointer.on * out;
x += (dx / d) * push;
y += (dy / d) * push;
}
}
return [x, y] as const;
};
for (const b of blocks) {
const [x, y] = place(b);
rect(x, y, cell);
}
const base = 1 - mix;
if (tint !== ink) {
ctx.fillStyle = tint;
for (const b of blocks) {
if (!b.tint) continue;
const away = Math.hypot(b.x - b.hx, b.y - b.hy) / (cell * 3);
if (away < 0.02) continue;
ctx.globalAlpha = Math.min(1, away) * base;
const [x, y] = place(b);
rect(x, y, cell);
}
}
ctx.globalAlpha = 1;
if (mix > 0) vector(mix);
};
const scatterTarget = (b: Block) => {
b.sx = Math.floor(rand() * cols) * cell;
b.sy = Math.floor(rand() * rows) * cell;
};
const tick = (now: number, dt: number) => {
if (hovered) {
level = Math.min(3, level + dt / STEP_IN);
fade = Math.max(0, fade - dt / FADE_BACK);
}
let settled = true;
for (const b of blocks) {
const scattered = level >= 3 && hovered === now >= b.at;
if (scattered && now >= b.hop && Math.hypot(b.x - b.sx, b.y - b.sy) < cell * 0.3) {
// Wander one to three cells along the grid, like a walk through a maze.
const [dx, dy] = [[1, 0], [-1, 0], [0, 1], [0, -1]][Math.floor(rand() * 4)];
const n = 1 + Math.floor(rand() * 3);
b.sx = Math.max(0, Math.min(cols - 1, Math.round(b.sx / cell) + dx * n)) * cell;
b.sy = Math.max(0, Math.min(rows - 1, Math.round(b.sy / cell) + dy * n)) * cell;
b.hop = now + 0.6 + rand() * 2.4;
}
const tx = scattered ? b.sx : b.hx;
const ty = scattered ? b.sy : b.hy;
const { k, c } = scattered ? SCATTER : RETURN;
b.vx += ((tx - b.x) * k - b.vx * c) * dt;
b.vy += ((ty - b.y) * k - b.vy * c) * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (scattered || Math.hypot(tx - b.x, ty - b.y) > 0.15 || Math.hypot(b.vx, b.vy) > 4) {
settled = false;
} else {
b.x = b.hx;
b.y = b.hy;
b.vx = b.vy = 0;
}
}
// Only step back down to the vector logo once every block is home.
if (!hovered && settled && level > 0) {
fade = Math.min(1, fade + dt / FADE_OUT);
if (fade >= 1) level = fade = 0;
}
pointer.on += ((hovered ? 1 : 0) - pointer.on) * Math.min(1, dt * 10);
};
const loop = (ms: number) => {
const now = ms / 1000;
const dt = Math.min(1 / 30, now - last);
last = now;
// Small substeps keep the springs stable on slow frames.
const n = Math.ceil(dt / (1 / 120));
for (let i = 0; i < n; i++) tick(now, dt / n);
draw();
frame = hovered || level > 0 ? requestAnimationFrame(loop) : 0;
};
const wake = () => {
if (frame) return;
last = clock();
frame = requestAnimationFrame(loop);
};
const local = (e: PointerEvent) => {
const box = canvas.getBoundingClientRect();
return {
x: ((e.clientX - box.left) * width) / box.width,
y: ((e.clientY - box.top) * height) / box.height,
};
};
const enter = (p: { x: number; y: number }) => {
if (hovered) return;
hovered = true;
const now = clock();
// The mosaic steps finish first, then blocks leave in a ripple from the pointer.
const ready = now + (3 - level) * STEP_IN;
for (const b of blocks) {
const home = b.x === b.hx && b.y === b.hy;
if (home) scatterTarget(b);
const d = Math.hypot(b.hx - p.x, b.hy - p.y);
b.at = home ? ready + Math.min(0.3, d / 1600) + rand() * 0.06 : now;
b.hop = b.at + 0.8 + rand() * 2;
}
pointer.x = p.x;
pointer.y = p.y;
wake();
};
const leave = () => {
if (!hovered) return;
hovered = false;
const now = clock();
// Blocks fly home in a sweep from the left, so the logo rebuilds as it reads.
for (const b of blocks) {
const out = b.x !== b.hx || b.y !== b.hy;
b.at = out ? now + ((b.hx - logoX) / logoWidth) * 0.25 + rand() * 0.06 : now;
}
wake();
};
const onEnter = (e: PointerEvent) => {
if (e.pointerType === "mouse" || e.pointerType === "pen") enter(local(e));
};
const onMove = (e: PointerEvent) => {
const p = local(e);
pointer.x = p.x;
pointer.y = p.y;
if (e.pointerType !== "touch") enter(p);
};
const onLeave = (e: PointerEvent) => {
if (e.pointerType !== "touch") leave();
};
// Touch has no hover, so a tap scatters the blocks and they return on their own.
const onDown = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
enter(local(e));
window.clearTimeout(release);
release = window.setTimeout(leave, 1600);
};
const resize = new ResizeObserver(() => {
cancelAnimationFrame(frame);
frame = 0;
layout();
});
resize.observe(canvas);
const theme = new MutationObserver(() => {
ink = readColor("var(--foreground)");
tint = accent ? readColor(accent) : ink;
if (!frame) draw();
});
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
if (!reduceMotion) {
canvas.addEventListener("pointerenter", onEnter);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerleave", onLeave);
canvas.addEventListener("pointerdown", onDown);
}
return () => {
cancelAnimationFrame(frame);
window.clearTimeout(release);
resize.disconnect();
theme.disconnect();
canvas.removeEventListener("pointerenter", onEnter);
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerleave", onLeave);
canvas.removeEventListener("pointerdown", onDown);
};
}, [mark, pixelSize, wideWidth, align, accent, reduceMotion]);
return (
<div className={cn("relative w-full", className)}>
<span ref={probeRef} aria-hidden="true" className="hidden" />
<canvas
ref={canvasRef}
aria-hidden="true"
width={1080}
height={240}
className="block h-auto w-full touch-pan-y"
/>
<span className="sr-only">{label}</span>
</div>
);
}