Signature
An animated SVG signature effect that draws out text as if hand-written.
Preview supplied by Arlan Marat; live previews were recorded from Arlan's Vault when no published video file was available. Preview: platform recorded · rights cleared.
Dot cut is an animated canvas study published directly in the Vault gallery.
VSource LandVaultWhy it stands out
Dot cut is an animated canvas study published directly in the Vault gallery.
Build this: a dense grid of CIRCLES that touch, with a symbol cut OUT of them as negative space — nothing is ever drawn on top, the glyph is the HOLE and the flat background shows through the mesh. THE GRID: ~42 circles across, on a square lattice at EXACTLY the pitch, so neighbours meet. The little concave diamonds between every four touching circles are the texture, and they only exist when the circles actually meet — pull them apart even slightly and the field stops being one connected surface and turns into polka dots on a background. The grid is INSET with a clear margin on all four sides (pitch derived from the width INCLUDING a 0.75-pitch margin per side, then only WHOLE rows fitted into the remaining height and centred) — a grid that bleeds off the edge produces half-circles at the frame, which read as a rendering bug rather than a crop. TWO STATES, CONTINUOUSLY BETWEEN: a cell is not simply present or absent. It opens from a solid disc into a RING by boring a hole outward from its centre, and every intermediate radius is valid, so a cell animates its own state change instead of snapping. Rings are punched as reverse-wound inner circles into ONE accumulating Path2D under the even-odd rule and the whole field is filled in a SINGLE submission — not a stroked pass per cell. That is the difference between a smooth card and a stuttering one: a few thousand per-cell draw calls is what makes this lag, one composite path is what fixes it. THE SYMBOL: rasterize the glyph on a 2D canvas AT GRID RESOLUTION (one sample per cell, not big-then-downsampled) and remove every circle the glyph covers. Size it to about 58% of the grid height with a margin of intact lattice all round, so it reads as a hole punched in a surface rather than a frame. Only bold, closed, high-contrast forms survive at this cell count — thin strokes and fine joins (arrows, punctuation, most lowercase) dissolve into noise. SIX SCENES ON TWO INDEPENDENT PATTERN AXES: one letter (A) and five full-bleed patterns (rings, columns, checker, boxes, bars). The scene CARVES; a second pattern (drift / grain / swell / streak) simultaneously TEXTURES whichever cells the carve leaves standing, so two patterns are legible at once. The pair is always chosen to differ in AXIS from the cut — a diagonal texture under a square cut, rings under a brick cut — because two patterns on the same axis just read as one blurry pattern. The single letter is the only contained figure in the cycle, which is what gives it weight: surrounded by five moments where the whole surface reorganises, one centred shape lands as an event rather than another slide. THE TRANSITIONS: each scene arrives with both its own TIMING and its own MOTION. Timing is a per-cell delay — a diagonal wipe front, a ripple expanding from the centre, a per-cell random scatter, a collapse where the centre goes last out and first in, and a left-to-right column shutter. Motion is the second axis and is what stops every transition looking like the same fade reordered: as a cell changes it also travels — leaning along the wipe direction, lifting vertically on the ripple, flicking off at its own angle on the scatter, swelling before it pops on the collapse, sinking downward on the columns. Cells interpolate FROM their previous state TO the next one; starting every cell from zero makes open rings snap shut on the first morph frame, which reads as a glitch before the transition rather than as the transition. Keep the travel SMALL (well under one cell) and cap the swell so a growing cell never collides with its neighbour; the lattice has to stay legible throughout, otherwise cells read as loose particles instead of a surface. Each cell only moves during a short slice of the morph, so the field is completely still during the hold and every change is over quickly. Two-tone palettes cross-blend as scenes change, and the circle colour and background colour are always a matched pair so the glyph keeps reading as negative space. A pointer brush retracts circles near the cursor with a squared falloff, so you can rub the mesh away and watch it flow back. Framework-free Canvas 2D (arc() gives perfect edges for free, no shader) + a rAF loop; DPR-capped, pauses offscreen / when hidden / during route transitions, one static frame under reduced motion.
The complete, self-contained implementation follows, one file per block. It is framework-agnostic core logic — wire it into your own component and mount it on an element.
### dotcut/scenes.ts
```ts
export interface Scene {
kind: "text" | "rings" | "checker" | "bars" | "columns" | "boxes";
value?: string;
transition: TransitionKind;
palette: number;
style?: StyleKind;
}
export type TransitionKind =
| "wipe"
| "ripple"
| "scatter"
| "collapse"
| "columns";
export function cellMotion(
kind: TransitionKind,
t: number,
dir: number,
rand: number,
): { scale: number; dx: number; dy: number; spin: number } {
const u = Math.sin(Math.min(1, Math.max(0, t)) * Math.PI);
switch (kind) {
case "wipe":
return { scale: 1, dx: u * 0.16 * -dir, dy: 0, spin: 0 };
case "ripple":
return { scale: 1 - u * 0.10, dx: 0, dy: u * -0.13, spin: 0 };
case "scatter":
return {
scale: 1,
dx: u * 0.18 * Math.cos(rand * Math.PI * 2),
dy: u * 0.18 * Math.sin(rand * Math.PI * 2),
spin: 0,
};
case "collapse":
return { scale: 1 - u * 0.18, dx: 0, dy: 0, spin: 0 };
case "columns":
return { scale: 1, dx: 0, dy: u * 0.22, spin: 0 };
}
}
export type StyleKind = "drift" | "grain" | "swell" | "streak" | null;
function smooth01(v: number, e0: number, e1: number): number {
const t = Math.min(1, Math.max(0, (v - e0) / (e1 - e0)));
return t * t * (3 - 2 * t);
}
function hash2(x: number, y: number): number {
const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return s - Math.floor(s);
}
export const SCENES: Scene[] = [
{ kind: "text", value: "A", transition: "wipe", palette: 0, style: "drift" },
{ kind: "rings", transition: "ripple", palette: 1, style: "grain" },
{ kind: "columns", transition: "columns", palette: 2, style: "streak" },
{ kind: "checker", transition: "scatter", palette: 3, style: "swell" },
{ kind: "boxes", transition: "collapse", palette: 4, style: "grain" },
{ kind: "bars", transition: "wipe", palette: 5, style: "drift" },
];
export function styleField(
scene: Scene,
cols: number,
rows: number,
t: number,
out: Float32Array,
prev?: Scene,
) {
const cx = (cols - 1) / 2;
const cy = (rows - 1) / 2;
const maxR = Math.hypot(cols, rows) / 2;
const FLIP = 0.32;
const stateOf = (style: StyleKind | undefined, x: number, y: number): number => {
switch (style) {
case "drift": {
const a = Math.sin(x * 0.41 + y * 0.23);
const b = Math.sin(x * 0.17 - y * 0.53 + 2.1);
return smooth01((a + b) * 0.5, -0.15, 0.75);
}
case "grain": {
const n =
hash2(x, y) * 0.55 +
hash2(x + 1, y) * 0.15 +
hash2(x, y + 1) * 0.15 +
hash2(x + 1, y + 1) * 0.15;
return smooth01(n, 0.34, 0.86);
}
case "swell": {
const d = Math.hypot(x - cx, y - cy) / maxR;
const warp = Math.sin(Math.atan2(y - cy, x - cx) * 3.0) * 0.14;
return smooth01(1 - (d + warp), 0.28, 0.92);
}
case "streak": {
const s = Math.sin(x * 0.28 + y * 0.62);
const cut = Math.sin(x * 0.09 - y * 0.11 + 1.3) * 0.5 + 0.5;
return smooth01(s * cut, -0.05, 0.7);
}
default:
return 0;
}
};
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
let order = 0;
switch (scene.style) {
case "drift":
order = (x / cols) * 0.75 + Math.sin(y * 0.5) * 0.12 + 0.12;
break;
case "grain":
order = (x / cols) * 0.55 + (y / rows) * 0.25 + hash2(x, y) * 0.2;
break;
case "swell":
order = Math.hypot(x - cx, y - cy) / maxR;
break;
case "streak":
order = (x / cols) * 0.8 + (y / rows) * 0.2;
break;
}
const from = stateOf(prev?.style ?? scene.style, x, y);
const to = stateOf(scene.style, x, y);
const u = Math.min(1, Math.max(0, (t - order * (1 - FLIP)) / FLIP));
const eased = u * u * (3 - 2 * u);
out[y * cols + x] = from + (to - from) * eased;
}
}
}
export const PALETTES: [string, string][] = [
["#8aa9ff", "#1f45f5"],
["#ffd166", "#e5484d"],
["#b8f2c9", "#0f8a5f"],
["#ffc2e2", "#c81d77"],
["#c7d2fe", "#4338ca"],
["#fde68a", "#b45309"],
];
export function rasterize(
scene: Scene,
cols: number,
rows: number,
fontFamily: string,
): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(new ArrayBuffer(cols * rows)).fill(1);
const cx = (cols - 1) / 2;
const cy = (rows - 1) / 2;
if (scene.kind === "checker") {
const b = Math.max(2, Math.round(cols / 14));
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if ((Math.floor(x / b) + Math.floor(y / b)) % 2 === 0) out[y * cols + x] = 0;
}
}
return out;
}
if (scene.kind === "bars") {
const period = 3;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (Math.floor((x + y) / period) % 2 === 0) out[y * cols + x] = 0;
}
}
return out;
}
if (scene.kind === "columns") {
const bw = 4;
const bh = 3;
for (let y = 0; y < rows; y++) {
const band = Math.floor(y / bh);
const shift = band % 2 === 0 ? 0 : bw / 2;
for (let x = 0; x < cols; x++) {
if (Math.floor((x + shift) / bw) % 2 === 0) out[y * cols + x] = 0;
}
}
return out;
}
if (scene.kind === "boxes") {
const period = 2.5;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const d = Math.max(Math.abs(x - cx), Math.abs(y - cy));
if (Math.floor(d / period) % 2 === 0) out[y * cols + x] = 0;
}
}
return out;
}
if (scene.kind === "rings") {
const maxR = Math.hypot(cols, rows) / 2;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const d = Math.hypot(x - cx, y - cy) / maxR;
if (Math.floor(d * 6.0) % 2 === 0) out[y * cols + x] = 0;
}
}
return out;
}
const cv = document.createElement("canvas");
cv.width = cols;
cv.height = rows;
const ctx = cv.getContext("2d", { willReadFrequently: true });
if (!ctx) return out;
const text = (scene.value || "").trim();
if (!text) return out;
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, cols, rows);
ctx.fillStyle = "#fff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
let size = rows * 0.80;
ctx.font = `600 ${size}px ${fontFamily}`;
const maxW = cols * 0.36;
const m = ctx.measureText(text);
if (m.width > maxW) {
size *= maxW / m.width;
ctx.font = `600 ${size}px ${fontFamily}`;
}
const maxH = rows * 0.58;
const mm = ctx.measureText(text);
const gh = mm.actualBoundingBoxAscent + mm.actualBoundingBoxDescent;
if (gh > maxH) {
size *= maxH / gh;
ctx.font = `600 ${size}px ${fontFamily}`;
}
ctx.fillText(text, cols / 2, rows / 2 + rows * 0.02);
const data = ctx.getImageData(0, 0, cols, rows).data;
for (let i = 0; i < cols * rows; i++) {
if (data[i * 4] > 110) out[i] = 0;
}
return out;
}
export function cellDelay(
kind: TransitionKind,
x: number,
y: number,
cols: number,
rows: number,
rand: number,
): number {
const fx = cols > 1 ? x / (cols - 1) : 0;
const fy = rows > 1 ? y / (rows - 1) : 0;
switch (kind) {
case "wipe":
return Math.min(1, Math.max(0, (fx * 0.75 + fy * 0.25) * 0.85 + rand * 0.15));
case "ripple": {
const d = Math.hypot(fx - 0.5, fy - 0.5) / 0.707;
return Math.min(1, d * 0.9 + rand * 0.10);
}
case "scatter":
return rand;
case "collapse": {
const d = Math.hypot(fx - 0.5, fy - 0.5) / 0.707;
return Math.min(1, (1 - d) * 0.85 + rand * 0.15);
}
case "columns":
return Math.min(1, fx * 0.9 + rand * 0.10);
}
}
```
### dotcut/engine.ts
```ts
import {
PALETTES,
SCENES,
cellDelay,
cellMotion,
rasterize,
styleField,
type Scene,
} from "./scenes";
const COLS = 42;
const HOLD_MS = 600;
const MORPH_MS = 520;
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
const easeInOut = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
function hash(n: number): number {
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
export interface DotCutParams {
cols: number;
squareness: number;
hold: number;
morph: number;
brush: number;
fill: number;
}
export const DEFAULTS: DotCutParams = {
cols: COLS,
squareness: 0,
hold: HOLD_MS,
morph: MORPH_MS,
brush: 1.6,
fill: 1.0,
};
export class DotCut {
private host: HTMLElement;
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D | null = null;
readonly params: DotCutParams = { ...DEFAULTS };
private cols = COLS;
private rows = 12;
private pitch = 10;
private ox = 0;
private oy = 0;
private target = new Uint8Array(0);
private live = new Float32Array(0);
private from = new Float32Array(0);
private delay = new Float32Array(0);
private rnd = new Float32Array(0);
private prog = new Float32Array(0);
private dir = new Float32Array(0);
private bore = new Float32Array(0);
private styleT = 0;
private sceneIdx = 0;
private phase: "hold" | "morph" = "hold";
private phaseT = 0;
private clock = 0;
private paletteMix = 1;
private prevPalette = 0;
private prevScene = 0;
private pointer: { x: number; y: number } | null = null;
private raf = 0;
private last = 0;
private running = false;
private dpr = 1;
private ro: ResizeObserver | null = null;
private disposed = false;
private fontFamily = "sans-serif";
constructor(host: HTMLElement, fontFamily?: string) {
this.host = host;
if (fontFamily) this.fontFamily = fontFamily;
this.canvas = document.createElement("canvas");
this.canvas.style.cssText = "display:block;width:100%;height:100%";
host.appendChild(this.canvas);
this.ctx = this.canvas.getContext("2d");
if (!this.ctx) return;
this.resize();
this.ro = new ResizeObserver(() => this.resize());
this.ro.observe(host);
}
get ok() {
return !!this.ctx;
}
private applyScene(scene: Scene, instant: boolean) {
const next = rasterize(scene, this.cols, this.rows, this.fontFamily);
this.from.set(this.live);
this.target = next;
for (let y = 0; y < this.rows; y++) {
for (let x = 0; x < this.cols; x++) {
const i = y * this.cols + x;
this.delay[i] = cellDelay(
scene.transition,
x,
y,
this.cols,
this.rows,
this.rnd[i],
);
}
}
if (instant) {
for (let i = 0; i < next.length; i++) this.live[i] = next[i];
this.from.set(this.live);
}
}
private resize() {
const ctx = this.ctx;
if (!ctx || this.disposed) return;
const w = this.host.clientWidth;
const h = this.host.clientHeight;
if (!w || !h) return;
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
this.canvas.width = Math.round(w * this.dpr);
this.canvas.height = Math.round(h * this.dpr);
const margin = 0.75;
this.cols = Math.max(6, Math.round(this.params.cols));
this.pitch = w / (this.cols + 2 * margin);
this.rows = Math.max(3, Math.floor((h - 2 * margin * this.pitch) / this.pitch));
this.ox = (w - this.cols * this.pitch) / 2;
this.oy = (h - this.rows * this.pitch) / 2;
const n = this.cols * this.rows;
this.target = new Uint8Array(n);
this.live = new Float32Array(n);
this.from = new Float32Array(n);
this.delay = new Float32Array(n);
this.rnd = new Float32Array(n);
this.prog = new Float32Array(n);
this.dir = new Float32Array(n);
this.bore = new Float32Array(n);
for (let i = 0; i < n; i++) this.rnd[i] = hash(i * 1.37 + 0.5);
this.applyScene(SCENES[this.sceneIdx], true);
if (!this.running) this.draw(0);
}
setParams(p: Partial<DotCutParams>) {
const needsGrid = p.cols !== undefined && p.cols !== this.params.cols;
Object.assign(this.params, p);
if (needsGrid) this.resize();
}
setPointer(p: { x: number; y: number } | null) {
this.pointer = p;
}
advance() {
this.prevScene = this.sceneIdx;
this.sceneIdx = (this.sceneIdx + 1) % SCENES.length;
this.prevPalette = SCENES[(this.sceneIdx - 1 + SCENES.length) % SCENES.length].palette;
this.paletteMix = 0;
this.phase = "morph";
this.phaseT = 0;
this.styleT = 0;
this.applyScene(SCENES[this.sceneIdx], false);
}
private step(dt: number) {
this.clock += dt;
this.phaseT += dt * 1000;
if (this.phase === "hold" && this.phaseT >= this.params.hold) {
this.advance();
} else if (this.phase === "morph" && this.phaseT >= this.params.morph) {
this.phase = "hold";
this.phaseT = 0;
}
const p = this.phase === "morph" ? Math.min(1, this.phaseT / this.params.morph) : 1;
const n = this.cols * this.rows;
for (let i = 0; i < n; i++) {
const d = this.delay[i];
const local = Math.min(1, Math.max(0, (p - d * 0.72) / 0.28));
const e = easeOut(local);
this.live[i] = this.from[i] + (this.target[i] - this.from[i]) * e;
const changing = this.from[i] !== this.target[i] && this.phase === "morph";
this.prog[i] = changing ? local : 0;
this.dir[i] = this.target[i] > this.from[i] ? 1 : -1;
}
this.paletteMix = Math.min(1, this.paletteMix + dt * 2.2);
this.styleT =
this.phase === "morph"
? Math.min(1, this.styleT + dt / (this.params.morph / 1000))
: 1;
styleField(
SCENES[this.sceneIdx],
this.cols,
this.rows,
this.styleT,
this.bore,
SCENES[this.prevScene],
);
}
private draw(dt: number) {
const ctx = this.ctx;
if (!ctx) return;
this.step(dt);
const W = this.canvas.width;
const H = this.canvas.height;
const s = this.dpr;
const scene = SCENES[this.sceneIdx];
const [cA, bA] = PALETTES[this.prevPalette % PALETTES.length];
const [cB, bB] = PALETTES[scene.palette % PALETTES.length];
const m = easeInOut(this.paletteMix);
const circle = mixHex(cA, cB, m);
const back = mixHex(bA, bB, m);
ctx.fillStyle = back;
ctx.fillRect(0, 0, W, H);
const pitch = this.pitch * s;
const r = pitch / 2;
const sq = Math.max(0, Math.min(1, this.params.squareness));
ctx.fillStyle = circle;
const solidPath = new Path2D();
const stroke = Math.max(1.1 * s, r * 0.30);
const brush = this.params.brush;
for (let y = 0; y < this.rows; y++) {
for (let x = 0; x < this.cols; x++) {
const i = y * this.cols + x;
let v = this.live[i];
if (v <= 0.004) continue;
if (this.pointer && brush > 0) {
const d = Math.hypot(x + 0.5 - this.pointer.x, y + 0.5 - this.pointer.y);
if (d < brush) v *= Math.min(1, (d / brush) ** 2);
}
if (v <= 0.004) continue;
const mo = cellMotion(scene.transition, this.prog[i], this.dir[i], this.rnd[i]);
const cx = this.ox * s + (x + 0.5) * pitch + mo.dx * pitch;
const cy = this.oy * s + (y + 0.5) * pitch + mo.dy * pitch;
const pop = 1;
const rr = r * v * pop * mo.scale * this.params.fill;
if (rr <= 0.3) continue;
const canRing = rr > 3.2 * s;
const bore = canRing ? (rr - stroke) * this.bore[i] : 0;
solidPath.moveTo(cx + rr, cy);
if (sq < 0.02) {
solidPath.arc(cx, cy, rr, 0, Math.PI * 2);
} else {
roundedSquare(solidPath, cx, cy, rr, sq);
}
if (bore > 0.4) {
solidPath.moveTo(cx + bore, cy);
solidPath.arc(cx, cy, bore, 0, Math.PI * 2, true);
}
}
}
ctx.fill(solidPath, "evenodd");
}
renderStill() {
this.phase = "hold";
this.phaseT = 0;
this.paletteMix = 1;
this.applyScene(SCENES[this.sceneIdx], true);
this.draw(0);
}
start() {
if (this.running || !this.ok || this.disposed) return;
this.running = true;
this.last = performance.now();
const tick = (now: number) => {
if (!this.running) return;
const dt = Math.min((now - this.last) / 1000, 1 / 30);
this.last = now;
this.draw(dt);
this.raf = requestAnimationFrame(tick);
};
this.raf = requestAnimationFrame(tick);
}
stop() {
this.running = false;
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = 0;
}
destroy() {
this.disposed = true;
this.stop();
this.ro?.disconnect();
this.ro = null;
this.ctx = null;
this.canvas.remove();
}
toCell(px: number, py: number) {
return { x: (px - this.ox) / this.pitch, y: (py - this.oy) / this.pitch };
}
}
function roundedSquare(
ctx: Pick<CanvasRenderingContext2D, "moveTo" | "lineTo" | "closePath">,
cx: number,
cy: number,
r: number,
sq: number,
) {
const n = 2 + sq * 8;
const steps = 22;
ctx.moveTo(cx + r, cy);
for (let i = 1; i <= steps; i++) {
const t = (i / steps) * Math.PI * 2;
const c = Math.cos(t);
const s = Math.sin(t);
const x = Math.sign(c) * Math.pow(Math.abs(c), 2 / n) * r;
const y = Math.sign(s) * Math.pow(Math.abs(s), 2 / n) * r;
ctx.lineTo(cx + x, cy + y);
}
ctx.closePath();
}
function mixHex(a: string, b: string, t: number): string {
const pa = parseInt(a.slice(1), 16);
const pb = parseInt(b.slice(1), 16);
const r = Math.round((((pa >> 16) & 255) * (1 - t) + ((pb >> 16) & 255) * t));
const g = Math.round((((pa >> 8) & 255) * (1 - t) + ((pb >> 8) & 255) * t));
const bl = Math.round(((pa & 255) * (1 - t) + (pb & 255) * t));
return `rgb(${r},${g},${bl})`;
}
```Discovery vocabulary
Related by governed terms
An animated SVG signature effect that draws out text as if hand-written.
More from Vault