Truchet tiles
due[i] = now + hypot(col − cx, row − cy) × 0.045
How it works
One tile, two rotations
Every cell contains the same drawing: two quarter-circle arcs of radius CELL / 2, each centred on a corner of the cell. Orientation 0 puts them on the top-left and bottom-right corners; orientation 1 mirrors that to the other diagonal.
Because each arc starts and ends exactly at the midpoint of a cell edge, and both orientations touch all four midpoints, any tile can sit beside any other tile and the curves still meet. That single constraint is what turns a grid of coin flips into unbroken loops — the pattern is emergent, nothing tracks a path.
The ripple
A click does not reshuffle the grid immediately. Each cell gets a pending orientation and a due timestamp set to the click time plus its Euclidean distance from the click, scaled down. The draw loop flips a cell only once the clock passes its due value.
Distance-as-delay is the whole trick: a scalar per cell buys an expanding circular wavefront with no wave simulation anywhere. Each cell also records the moment it flipped, and fades from the accent colour over 0.55s, which is what makes the front visible as it travels.
Cost
The grid is redrawn every frame rather than cached — at roughly 38px cells that is a few hundred arc calls, which Canvas 2D handles without complaint. State lives in three typed arrays sized to the grid, reallocated only on resize.
Source
'use client';
import { useRef } from 'react';import { useCanvasCraft, useCraftTheme } from '../useCraft';
const CELL = 38;
/** * Smith truchet tiles: every cell draws two quarter-arcs in one of two * orientations, and neighbouring arcs join into continuous loops. Click sends a * ripple outward from the point you hit, reshuffling tiles as it passes. */export default function TruchetTiles() { const theme = useCraftTheme(); const clock = useRef(0); const grid = useRef({ cols: 0, rows: 0, orient: new Uint8Array(0), pending: new Int8Array(0), due: new Float32Array(0), flash: new Float32Array(0), });
const seed = (w: number, h: number) => { const cols = Math.ceil(w / CELL) + 1; const rows = Math.ceil(h / CELL) + 1; const n = cols * rows; const orient = new Uint8Array(n); for (let i = 0; i < n; i++) orient[i] = Math.random() < 0.5 ? 0 : 1; grid.current = { cols, rows, orient, pending: new Int8Array(n).fill(-1), due: new Float32Array(n), flash: new Float32Array(n).fill(-99), }; };
const canvasRef = useCanvasCraft(({ ctx, w, h, t, pointer }) => { const g = grid.current; if (!g.cols) return; clock.current = t;
ctx.clearRect(0, 0, w, h);
const line = theme === 'dark' ? 'rgba(240,240,240,0.30)' : 'rgba(20,20,20,0.26)'; const hot = theme === 'dark' ? '#c5ff3b' : '#ff6600';
const hoverCol = pointer.inside ? Math.floor(pointer.x / CELL) : -1; const hoverRow = pointer.inside ? Math.floor(pointer.y / CELL) : -1;
ctx.lineCap = 'round'; const r = CELL / 2;
for (let row = 0; row < g.rows; row++) { for (let col = 0; col < g.cols; col++) { const i = row * g.cols + col;
if (g.pending[i] !== -1 && t >= g.due[i]) { g.orient[i] = g.pending[i] as 0 | 1; g.pending[i] = -1; g.flash[i] = t; }
const x = col * CELL; const y = row * CELL; const age = t - g.flash[i]; const flashing = age >= 0 && age < 0.55;
if (flashing) { const k = 1 - age / 0.55; ctx.strokeStyle = hot; ctx.globalAlpha = 0.3 + k * 0.7; ctx.lineWidth = 1.4 + k * 1.8; } else if (col === hoverCol && row === hoverRow) { ctx.strokeStyle = hot; ctx.globalAlpha = 0.9; ctx.lineWidth = 2; } else { ctx.strokeStyle = line; ctx.globalAlpha = 1; ctx.lineWidth = 1.4; }
ctx.beginPath(); if (g.orient[i] === 0) { ctx.moveTo(x + r, y); ctx.arc(x, y, r, 0, Math.PI / 2); ctx.moveTo(x + r, y + CELL); ctx.arc(x + CELL, y + CELL, r, Math.PI, Math.PI * 1.5); } else { ctx.moveTo(x + CELL, y + r); ctx.arc(x + CELL, y, r, Math.PI / 2, Math.PI); ctx.moveTo(x, y + r); ctx.arc(x, y + CELL, r, Math.PI * 1.5, Math.PI * 2); } ctx.stroke(); } }
ctx.globalAlpha = 1; }, seed);
const ripple = (e: React.PointerEvent<HTMLCanvasElement>) => { const g = grid.current; if (!g.cols) return; const rect = e.currentTarget.getBoundingClientRect(); const cx = (e.clientX - rect.left) / CELL; const cy = (e.clientY - rect.top) / CELL;
for (let row = 0; row < g.rows; row++) { for (let col = 0; col < g.cols; col++) { const i = row * g.cols + col; g.pending[i] = Math.random() < 0.5 ? 0 : 1; g.due[i] = clock.current + Math.hypot(col - cx, row - cy) * 0.045; } } };
return ( <canvas ref={canvasRef} onPointerDown={ripple} style={{ width: '100%', height: '100%', display: 'block', cursor: 'pointer' }} /> );}The shared loop every craft runs on, plus the noise and dither helpers.
'use client';
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
/** Decorative previews don't need every frame; the detail pages get the lot. */const PREVIEW_FPS = 30;
/** * Counts capped loops so each can be given a different phase. Without this they * all skip the same frames and compute on the same ones, so the index page does * nothing for one frame and every simulation at once on the next — which shows * up as a dropped frame rather than as evenly spread work. */let phaseCounter = 0;const PHASES = 3;
/** * rAF loop that only runs while the target is on screen and the tab is visible. * Each craft runs its own, so several visible at once is several animations' * worth of work — which is why the grid previews are capped: on the index a * handful of simulations compete for one main thread, and at a glance nobody can * tell 30 frames from 60. */export function useVisibleRaf( targetRef: RefObject<HTMLElement | null>, onFrame: (t: number, dt: number) => void,) { const cb = useRef(onFrame); cb.current = onFrame;
useEffect(() => { const el = targetRef.current; if (!el) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // The class that marks a stage as a thumbnail rather than the main event. const isPreview = el.closest('.craft-stage-preview') !== null; // A frame of slack, so a 30fps cap doesn't skip to 20 by missing a vsync. const minGap = isPreview ? 1000 / PREVIEW_FPS - 4 : 0; const phase = minGap ? ((phaseCounter++ % PHASES) * minGap) / PHASES : 0;
let raf = 0; let running = false; let visible = false; const start = performance.now(); let last = start - phase;
const loop = (now: number) => { raf = requestAnimationFrame(loop); if (now - last < minGap) return; const dt = Math.min((now - last) / 1000, 0.05); last = now; cb.current((now - start) / 1000, dt); };
const play = () => { if (running || !visible || document.hidden) return; running = true; // Keep this loop's phase on resume, or scrolling would resync them all. last = performance.now() - phase; raf = requestAnimationFrame(loop); };
const pause = () => { running = false; cancelAnimationFrame(raf); };
const io = new IntersectionObserver( ([entry]) => { visible = entry.isIntersecting; if (visible) play(); else pause(); }, { threshold: 0.05 }, ); io.observe(el);
const onVis = () => (document.hidden ? pause() : play()); document.addEventListener('visibilitychange', onVis);
// Reduced motion: paint one frame, then stop. if (reduced) { cb.current(0, 0); io.disconnect(); document.removeEventListener('visibilitychange', onVis); return; }
return () => { pause(); io.disconnect(); document.removeEventListener('visibilitychange', onVis); }; }, [targetRef]);}
export interface CraftFrame { ctx: CanvasRenderingContext2D; w: number; h: number; t: number; dt: number; pointer: { x: number; y: number; inside: boolean };}
/** * Canvas sized to its CSS box with DPR scaling, driven by useVisibleRaf. * `onInit` fires once per resize — use it to seed grids and particles. */export function useCanvasCraft( onFrame: (f: CraftFrame) => void, onInit?: (w: number, h: number) => void,) { const canvasRef = useRef<HTMLCanvasElement>(null); const box = useRef({ w: 0, h: 0 }); const pointer = useRef({ x: -9999, y: -9999, inside: false }); const frame = useRef(onFrame); const init = useRef(onInit); frame.current = onFrame; init.current = onInit;
useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return;
const resize = () => { const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); box.current = { w: rect.width, h: rect.height }; canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); init.current?.(rect.width, rect.height); };
resize(); const ro = new ResizeObserver(resize); ro.observe(canvas);
/* * Touch has no hover: pointermove only arrives while a finger is down, and * the browser stops sending it the moment it decides the gesture is a * scroll. So the position is also taken on pointerdown — otherwise a tap, * or a grab that hasn't moved yet, reads as no pointer at all. */ const at = (e: PointerEvent) => { const r = canvas.getBoundingClientRect(); pointer.current = { x: e.clientX - r.left, y: e.clientY - r.top, inside: true }; };
const down = (e: PointerEvent) => { at(e); // Keeps a drag that wanders off the canvas mid-gesture still tracking. try { canvas.setPointerCapture(e.pointerId); } catch { // Pointer already gone; nothing to capture. } };
const up = (e: PointerEvent) => { // A lifted finger stops existing, where a clicked mouse is still hovering. if (e.pointerType !== 'mouse') pointer.current.inside = false; };
const leave = () => { pointer.current.inside = false; };
canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', at); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', leave); canvas.addEventListener('pointerleave', leave);
return () => { ro.disconnect(); canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', at); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', leave); canvas.removeEventListener('pointerleave', leave); }; }, []);
useVisibleRaf(canvasRef, (t, dt) => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!ctx) return; frame.current({ ctx, w: box.current.w, h: box.current.h, t, dt, pointer: pointer.current }); });
return canvasRef;}
/** Current theme, kept in sync with the dock's toggle. Starts 'light' so SSR matches. */export function useCraftTheme(): 'light' | 'dark' { const [theme, setTheme] = useState<'light' | 'dark'>('light');
useLayoutEffect(() => { const el = document.documentElement; const read = () => setTheme((el.dataset.theme as 'light' | 'dark') ?? 'light'); read(); const mo = new MutationObserver(read); mo.observe(el, { attributes: true, attributeFilter: ['data-theme'] }); return () => mo.disconnect(); }, []);
return theme;}
/** Seeded PRNG, so anything generated looks the same on every visit. */export function mulberry32(seed: number) { return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
/** Ordered 4×4 Bayer matrix, normalised to 0..1. Shared by the dithered pieces. */export const BAYER4 = [ 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,].map(v => (v + 0.5) / 16);
/** Cheap hash-based value noise — enough character for a flow field, no lookup tables. */export function noise2(x: number, y: number): number { const xi = Math.floor(x); const yi = Math.floor(y); const xf = x - xi; const yf = y - yi; const u = xf * xf * (3 - 2 * xf); const v = yf * yf * (3 - 2 * yf);
const h = (a: number, b: number) => { let n = Math.imul(a, 374761393) + Math.imul(b, 668265263); n = Math.imul(n ^ (n >>> 13), 1274126177); return ((n ^ (n >>> 16)) >>> 0) / 4294967295; };
return ( h(xi, yi) * (1 - u) * (1 - v) + h(xi + 1, yi) * u * (1 - v) + h(xi, yi + 1) * (1 - u) * v + h(xi + 1, yi + 1) * u * v );}