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.
Glitch word is an animated canvas study published directly in the Vault gallery.
VSource LandVaultWhy it stands out
Glitch word is an animated canvas study published directly in the Vault gallery.
Build this: a word on a coloured badge, TEARING ITSELF APART in bursts — the corrupted-text glitch, as DOM and the Web Animations API rather than canvas or a shader. THE STRUCTURE IS THE EFFECT, and it is the thing most rebuilds get wrong: put the badge and the word in ONE wrapper, and clone that whole wrapper N times (10 is plenty) with every copy stacked in the SAME CSS grid cell (col-start-1 row-start-1) so the copies sit exactly on the original at any size with no absolute positioning to sync. Clone only the TEXT and you get a word tearing in front of a perfectly static rectangle, which reads as two unrelated things; cloning the wrapper means a slice clips and shoves the block and the glyphs TOGETHER, so the badge appears to move while carrying no animation of its own. The same applies to the shake: put the shake target on the WRAPPER, not the inner text, or it jitters the letters and leaves the badge behind. PURE GEOMETRY, NO COLOUR. Do not tint the slices, do not hue-rotate them, do not split them into RGB channels — displaced copies read as a bad decode precisely BECAUSE they are identical, and colouring them turns a mis-registration into a rainbow. The only colour is the badge itself. THE ENVELOPE drives everything: zero outside the burst window, ramping to 1 at a peak set at ~30% of the span so the attack is fast and the decay is long (a symmetric triangle reads as a deliberate fade; fast-in slow-out reads as a fault settling). Past the window's end it goes NEGATIVE for a short tail — about a fifth of the peak, decaying to zero — which flips every displacement's direction and makes a burst LAND rather than stop. Real hardware overcorrects; returning cleanly to true is the one thing a decoder never does. Because the visibility test then sees negative values, test the ABSOLUTE envelope or the settle is hidden and never shows. FIVE THINGS BUILD ON THAT ENVELOPE. (1) Each layer waits for its own share of the intensity before appearing — threshold = (index+1)/(count+1) — so the burst BUILDS from one band to all of them instead of switching on at full density. (2) One rogue slice per cycle takes ~3x the normal shove; corruption is never evenly distributed and its absence is what makes uniform tearing look synthetic. (3) Couple band HEIGHT to displacement (scale ~0.35 + heightRatio*0.65) so thin bands barely move and thick ones fly — independent randoms let a hairline band cross the whole box, which reads as noise rather than as something physical. (4) A rare full-frame drop: every 8-12 bursts, ONE step where the entire unit is invisible. One step, not two — two reads as a flicker, one reads as a frame that did not arrive. Count it rather than rolling per cycle, or two land back to back. (5) Rare single-step micro-glitches OUTSIDE the burst, gated to one layer only, so the quiet stretches are not empty. STEPPED KEYFRAMES for anything displaced: steps(n, jump-start), never interpolated — sliding between offsets reads as motion blur, snapping reads as digital corruption. The badge's CORNER RADIUS is the exception and should EASE, because a radius is a continuous property of the shape and stepping it makes the block look randomly redrawn rather than flexing. RE-ROLL EVERY CYCLE and chain the next pass off the previous animation's finished promise, not a setInterval which queues catch-up cycles in a throttled tab. THE BADGE is a small layered material — a drifting fill, a top-lit gradient, an inner top highlight, an inner bottom shade, an occlusion ring instead of a border (a border would fight the radius animation as a separate animatable property), and a two-part contact shadow. During a burst the shadow OFFSETS OPPOSITE to the shove, as though the block moved and its shadow has not caught up. Careful: a keyframe that sets box-shadow replaces the WHOLE stack, so the static parts must be restated in every frame or they vanish for the length of the burst. Let the badge's fill drift slowly through a narrow hue range (a green staying green), run that animation on EACH copy rather than a shared ancestor, and give each copy a different negative animation-delay — then a tear reveals a seam, because the surface disagrees with itself. THE SCRAMBLE is independent of the layers: every ~50ms each letter has a small chance of being replaced, and every OTHER tick restores the real word so it strobes between clean and corrupt rather than staying unreadable. Substitute NEAR-LOOKALIKES of similar width (o->c, i->l, n->m), not arbitrary punctuation — the word stays readable and feels subtly wrong, which is more unsettling than obvious symbol noise. Keep the substitutes ASCII unless you have verified the face carries the Unicode homoglyphs; a missing glyph renders as a tofu box, a far worse failure than a slightly-off letter. Gate the scramble on the SAME envelope as the layers or the letters keep flickering through the quiet stretch while nothing is torn. IN A PROPORTIONAL FACE, pin the badge with an invisible copy of the RESTING word in the same grid cell: every scrambled letter is a different width, so without an anchor the cell resizes on each tick and the badge visibly breathes. A monospace face makes this unnecessary. TWO PRESETS, one engine: at rest a longer cycle with a burst over about a third of it; on hover a short cycle whose burst covers most of it, so it barely resolves. Magnetism is worth adding on fine pointers — translate the whole unit a FRACTION of the pointer's offset from centre (~0.18), with a falloff that squares the normalised distance so the pull only exists near the word, and an eased release with slight overshoot. Give it its own wrapper: the base already animates transform for the shake and a slow resting drift, and a third writer on that property fights them, where nesting composes cleanly. The resting drift should use composite:'add' for the same reason. Pauses offscreen / when hidden / during route transitions, and under reduced motion it renders the plain word with no animation at all.
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.
### glitch-word/engine.ts
```ts
const LOOKALIKE: Record<string, string> = {
a: "eo", b: "hd", c: "eo", d: "bh", e: "ca", f: "tr", g: "qy",
h: "bn", i: "lj", j: "il", k: "hx", l: "il", m: "nw", n: "mh",
o: "ce", p: "qb", q: "pg", r: "nf", s: "z5", t: "fl", u: "vn",
v: "uy", w: "vm", x: "kz", y: "vg", z: "sx",
};
const SCRAMBLE_CHARS = "!@#$%^&*()_+-=[]{}|;:,.<>?";
function swap(ch: string): string {
const near = LOOKALIKE[ch.toLowerCase()];
if (near) return near[Math.floor(Math.random() * near.length)];
return SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)];
}
export interface GlitchOptions {
duration: number;
sliceCount: number;
velocity: number;
minHeight: number;
maxHeight: number;
maxOffset: number;
shakeAmplitude: number;
spanStart: number;
spanEnd: number;
peakAt: number;
rogueMultiplier: number;
cornerJitter: number;
driftPx: number;
scrambleRate: number;
scrambleInterval: number;
}
export const IDLE: GlitchOptions = {
duration: 1800,
sliceCount: 7,
velocity: 15,
minHeight: 0.02,
maxHeight: 0.18,
maxOffset: 20,
shakeAmplitude: 0.13,
spanStart: 0.5,
spanEnd: 0.84,
scrambleRate: 0.06,
scrambleInterval: 50,
peakAt: 0.3,
rogueMultiplier: 3,
cornerJitter: 4,
driftPx: 0.5,
};
export const ACTIVE: GlitchOptions = {
duration: 340,
sliceCount: 10,
velocity: 18,
minHeight: 0.02,
maxHeight: 0.2,
maxOffset: 38,
shakeAmplitude: 0.26,
spanStart: 0.22,
spanEnd: 0.9,
scrambleRate: 0.14,
scrambleInterval: 45,
peakAt: 0.3,
rogueMultiplier: 3.5,
cornerJitter: 6,
driftPx: 0.5,
};
const REST_SHADOW = [
"inset 0 1px 0 -0.5px rgba(255,255,255,0.4)",
"inset 0 -1px 0 -0.5px rgba(0,32,15,0.3)",
"inset 0 0 0 1px rgba(0,40,18,0.09)",
"0 1px 2px rgba(6,46,24,0.22)",
"0 4px 10px -4px rgba(6,46,24,0.28)",
].join(", ");
const DROP_EVERY = 8;
const DROP_JITTER = 5;
const SHADOW_LAG = 7;
const OVERSHOOT_SPAN = 0.14;
const OVERSHOOT_PEAK = 0.22;
function envelope(o: GlitchOptions, t: number): number {
if (t < o.spanStart) return 0;
const span = o.spanEnd - o.spanStart;
if (t > o.spanEnd) {
const tail = (t - o.spanEnd) / (span * OVERSHOOT_SPAN);
return tail < 1 ? -OVERSHOOT_PEAK * (1 - tail) : 0;
}
const peak = o.spanStart + span * o.peakAt;
return t < peak
? (t - o.spanStart) / (peak - o.spanStart)
: (o.spanEnd - t) / (o.spanEnd - peak);
}
function jolt(o: GlitchOptions, t: number): number {
return (Math.random() - 0.5) * 2 * envelope(o, t);
}
function band(o: GlitchOptions): { path: string; heightRatio: number } {
const range = o.maxHeight - o.minHeight;
const heightRatio = Math.random();
const h = o.minHeight + heightRatio * range;
const y = Math.random() * (1 - h);
const top = (y * 100).toFixed(2);
const bot = ((y + h) * 100).toFixed(2);
return {
path: `polygon(0% ${top}%, 100% ${top}%, 100% ${bot}%, 0% ${bot}%)`,
heightRatio,
};
}
function sliceFrames(
o: GlitchOptions,
index: number,
rogue: number,
): Keyframe[] {
const steps = Math.max(1, Math.floor((o.velocity * o.duration) / 1000) + 1);
const threshold = ((index + 1) / (o.sliceCount + 1)) * 0.9;
const push = index === rogue ? o.maxOffset * o.rogueMultiplier : o.maxOffset;
const out: Keyframe[] = [];
for (let i = 0; i < steps; i++) {
const t = i / steps;
const e = envelope(o, t);
if (Math.abs(e) < threshold) {
const flicker = index === 0 && Math.random() < 0.035;
if (!flicker) {
out.push({ opacity: "0", transform: "none", clipPath: "unset" });
continue;
}
const b = band(o);
out.push({
opacity: "1",
transform: `translate3d(${((Math.random() - 0.5) * o.maxOffset * 0.3).toFixed(2)}%,0,0)`,
clipPath: b.path,
});
continue;
}
const b = band(o);
const scale = 0.35 + b.heightRatio * 0.65;
out.push({
opacity: "1",
transform: `translate3d(${(jolt(o, t) * push * scale).toFixed(2)}%,0,0)`,
clipPath: b.path,
});
}
return out;
}
function dropFrames(o: GlitchOptions): Keyframe[] {
const steps = Math.max(1, Math.floor((o.velocity * o.duration) / 1000) + 1);
const peakStep = Math.round(steps * (o.spanStart + (o.spanEnd - o.spanStart) * o.peakAt));
const out: Keyframe[] = [];
for (let i = 0; i < steps; i++) {
out.push({ opacity: i === peakStep ? "0" : "1" });
}
return out;
}
function badgeFrames(o: GlitchOptions, radius: number): Keyframe[] {
const steps = Math.max(1, Math.floor((o.velocity * o.duration) / 1000) + 1);
const out: Keyframe[] = [];
for (let i = 0; i < steps; i++) {
const t = i / steps;
const e = envelope(o, t);
if (e === 0) {
out.push({ borderRadius: `${radius}px`, boxShadow: REST_SHADOW });
continue;
}
const r = Math.max(0, radius + jolt(o, t) * o.cornerJitter);
const lag = (-jolt(o, t) * SHADOW_LAG).toFixed(1);
out.push({
borderRadius: `${r.toFixed(2)}px`,
boxShadow: [
"inset 0 1px 0 -0.5px rgba(255,255,255,0.4)",
"inset 0 -1px 0 -0.5px rgba(0,32,15,0.3)",
"inset 0 0 0 1px rgba(0,40,18,0.09)",
`${lag}px 1px 2px rgba(6,46,24,0.22)`,
`${lag}px 4px 10px -4px rgba(6,46,24,0.28)`,
].join(", "),
});
}
return out;
}
function shakeFrames(o: GlitchOptions): Keyframe[] {
const steps = Math.max(1, Math.floor((o.velocity * o.duration) / 1000) + 1);
const out: Keyframe[] = [];
for (let i = 0; i < steps; i++) {
const t = i / steps;
const x = jolt(o, t) * o.shakeAmplitude * 100;
const y = jolt(o, t) * o.shakeAmplitude * 100;
out.push({ transform: `translate3d(${x.toFixed(2)}%,${y.toFixed(2)}%,0)` });
}
return out;
}
export class GlitchWord {
private base: HTMLElement;
private layers: HTMLElement[];
private badge: HTMLElement | null;
private badgeRadius = 8;
private drift: Animation | null = null;
private unit: HTMLElement | null = null;
private sinceDrop = 0;
private word: string;
private opts: GlitchOptions = IDLE;
private anims: Animation[] = [];
private scrambleTimer: number | null = null;
private scrambleTick = 0;
private cycleStart = 0;
private running = false;
private reduced: boolean;
constructor(
base: HTMLElement,
layers: HTMLElement[],
word: string,
reduced = false,
) {
this.unit = base.closest<HTMLElement>("[data-glitch-magnet]");
this.badge = base.querySelector<HTMLElement>("[data-glitch-badge]");
if (this.badge) {
const r = parseFloat(getComputedStyle(this.badge).borderTopLeftRadius);
if (!Number.isNaN(r)) this.badgeRadius = r;
}
this.base = base;
this.layers = layers;
this.word = word;
this.reduced = reduced;
}
setOptions(o: GlitchOptions) {
this.opts = o;
if (this.running) {
this.cancel();
this.run();
}
}
start() {
if (this.running || this.reduced) return;
this.running = true;
this.run();
this.startScramble();
this.startDrift();
}
private startDrift() {
if (this.drift || this.reduced) return;
const px = this.opts.driftPx;
this.drift = this.base.animate(
[
{ transform: "translate3d(0,0,0)" },
{ transform: `translate3d(${px}px,${-px * 0.6}px,0)` },
{ transform: `translate3d(${-px * 0.8}px,${px}px,0)` },
{ transform: `translate3d(${px * 0.5}px,${px * 0.7}px,0)` },
{ transform: "translate3d(0,0,0)" },
],
{
duration: 9400,
iterations: Infinity,
easing: "ease-in-out",
composite: "add",
},
);
}
private stopDrift() {
this.drift?.cancel();
this.drift = null;
}
stop() {
this.running = false;
this.cancel();
this.stopScramble();
this.stopDrift();
}
private run() {
const o = this.opts;
const timing: KeyframeAnimationOptions = {
duration: o.duration,
iterations: 1,
easing: `steps(${Math.max(1, Math.floor((o.velocity * o.duration) / 1000) + 1)}, jump-start)`,
fill: "none",
};
const rogue = Math.floor(Math.random() * o.sliceCount);
if (this.unit && ++this.sinceDrop >= DROP_EVERY + Math.floor(Math.random() * DROP_JITTER)) {
this.sinceDrop = 0;
this.anims.push(
this.unit.animate(dropFrames(o), {
duration: o.duration,
iterations: 1,
easing: `steps(${Math.max(1, Math.floor((o.velocity * o.duration) / 1000) + 1)}, jump-start)`,
fill: "none",
}),
);
}
this.anims = [
this.base.animate(shakeFrames(o), timing),
...this.layers
.slice(0, o.sliceCount)
.map((el, i) => el.animate(sliceFrames(o, i, rogue), timing)),
];
if (this.badge) {
this.anims.push(
this.badge.animate(badgeFrames(o, this.badgeRadius), {
duration: o.duration,
iterations: 1,
easing: "ease-in-out",
fill: "none",
}),
);
}
this.cycleStart = performance.now();
this.anims[0]?.finished
.then(() => {
if (this.running) this.run();
})
.catch(() => {
});
}
private cancel() {
for (const a of this.anims) {
try {
a.cancel();
} catch {}
}
this.anims = [];
for (const el of this.layers) {
el.style.opacity = "0";
el.style.transform = "none";
el.style.clipPath = "none";
el.style.removeProperty("color");
el.style.removeProperty("filter");
}
this.base.style.transform = "none";
if (this.unit) this.unit.style.removeProperty("opacity");
}
private startScramble() {
this.stopScramble();
this.cycleStart = performance.now();
const tick = () => {
const o = this.opts;
const phase = ((performance.now() - this.cycleStart) % o.duration) / o.duration;
if (envelope(o, phase) === 0) {
this.setText(this.word);
return;
}
if (++this.scrambleTick % 2 !== 0) {
this.setText(this.word);
return;
}
let out = this.word;
for (let i = 0; i < out.length; i++) {
if (Math.random() < o.scrambleRate) {
const c = swap(out[i]);
out = out.slice(0, i) + c + out.slice(i + 1);
}
}
this.setText(out);
};
tick();
this.scrambleTimer = window.setInterval(tick, this.opts.scrambleInterval);
}
private stopScramble() {
if (this.scrambleTimer !== null) window.clearInterval(this.scrambleTimer);
this.scrambleTimer = null;
this.setText(this.word);
}
private setText(s: string) {
const baseSlot =
this.base.querySelector<HTMLElement>("[data-glitch-text]") ?? this.base;
baseSlot.textContent = s;
for (const el of this.layers) {
const slot = el.querySelector<HTMLElement>("[data-glitch-text]") ?? el;
slot.textContent = s;
}
}
destroy() {
this.stop();
}
}
```
### glitch-word/GlitchWordCard.tsx
```ts
"use client";
import { useEffect, useRef } from "react";
import { ACTIVE, GlitchWord, IDLE } from "./engine";
import { onTransitionChange } from "../../lib/view-transition";
const WORD = "glitching";
const LAYERS = 10;
export function GlitchWordCard({ bare = false }: { bare?: boolean } = {}) {
void bare;
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const base = host.querySelector<HTMLElement>("[data-glitch-base]");
const layers = Array.from(
host.querySelectorAll<HTMLElement>("[data-glitch-layer]"),
);
if (!base) return;
const engine = new GlitchWord(base, layers, WORD, reduced);
let onScreen = false;
let hidden = false;
let inTransition = false;
let hovered = false;
const magnet = host.querySelector<HTMLElement>("[data-glitch-magnet]");
const PULL = 0.18;
let raf = 0;
const onMove = (e: PointerEvent) => {
if (!magnet || reduced) return;
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
const r = host.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const dx = e.clientX - cx;
const dy = e.clientY - cy;
const reach = Math.hypot(r.width, r.height) / 2;
const falloff = Math.max(0, 1 - Math.hypot(dx, dy) / reach);
const k = PULL * falloff * falloff;
magnet.style.transition = "none";
magnet.style.transform = `translate3d(${(dx * k).toFixed(2)}px,${(dy * k).toFixed(2)}px,0)`;
});
};
const onLeaveMagnet = () => {
if (!magnet) return;
if (raf) { cancelAnimationFrame(raf); raf = 0; }
magnet.style.transition = "transform 620ms var(--ease-amo)";
magnet.style.transform = "translate3d(0,0,0)";
};
const sync = () => {
if (reduced) return;
if (onScreen && !hidden && !inTransition) {
engine.start();
} else {
engine.stop();
}
};
const io = new IntersectionObserver(
(es) => {
onScreen = es.some((e) => e.isIntersecting);
sync();
},
{ rootMargin: "200px" },
);
io.observe(host);
const onVis = () => {
hidden = document.hidden;
sync();
};
document.addEventListener("visibilitychange", onVis);
const offTransition = onTransitionChange((active) => {
inTransition = active;
sync();
});
const fine = window.matchMedia("(pointer: fine)").matches;
const onEnter = () => {
if (hovered) return;
hovered = true;
engine.setOptions(ACTIVE);
host.dataset.hot = "true";
};
const onLeave = () => {
if (!hovered) return;
hovered = false;
engine.setOptions(IDLE);
delete host.dataset.hot;
};
if (fine && !reduced) {
host.addEventListener("pointerenter", onEnter);
host.addEventListener("pointerleave", onLeave);
host.addEventListener("pointermove", onMove);
host.addEventListener("pointerleave", onLeaveMagnet);
}
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
offTransition();
if (fine && !reduced) {
host.removeEventListener("pointerenter", onEnter);
host.removeEventListener("pointerleave", onLeave);
host.removeEventListener("pointermove", onMove);
host.removeEventListener("pointerleave", onLeaveMagnet);
}
if (raf) cancelAnimationFrame(raf);
engine.destroy();
};
}, []);
const CELL = "col-start-1 row-start-1";
const TEXT =
"font-sans font-medium text-[30px] leading-none tracking-[0.01em] whitespace-pre";
return (
<div
ref={hostRef}
data-canvas-card
role="img"
aria-label="The word 'glitching' in a small badge, tearing itself apart: stacked copies of the text clipped into bands and shoved sideways, while a few letters flicker into punctuation"
className="group relative flex aspect-[1344/620] w-full select-none items-center justify-center overflow-hidden rounded-[12px] border border-[var(--border-line)] bg-[var(--bg-hover)]"
>
{}
{}
<span
data-glitch-magnet
className="relative inline-block will-change-transform"
>
<span className="relative grid place-items-center">
{}
<span aria-hidden="true" className={`${CELL} ${TEXT} invisible`}>
{WORD}
</span>
{/* The original. It carries the real text, so the scramble writes here
and the shake moves it. */}
{/* data-glitch-base is on the WRAPPER, not on the text.
The engine translates this element for the shake. With the attribute
on the inner span it moved the glyphs alone and left the badge — its
own sibling — perfectly still, which is the whole reason the block
looked static while the letters jittered. The text slot below is
marked separately so the scramble still knows where to write. */}
<span data-glitch-base className={`${CELL} relative z-10`}>
<span
data-glitch-badge
className="gw-badge absolute -inset-x-3 -top-1 -bottom-1 rounded-[8px]"
/>
<span data-glitch-text className={`${TEXT} relative text-white`}>
{WORD}
</span>
</span>
{}
{Array.from({ length: LAYERS }).map((_, i) => (
<span
key={i}
data-glitch-layer
aria-hidden="true"
style={{ opacity: 0 }}
className={`${CELL} pointer-events-none relative z-10`}
>
{}
<span
className="gw-badge absolute -inset-x-3 -top-1 -bottom-1 rounded-[8px]"
style={{ animationDelay: `${-(i * 2.4).toFixed(1)}s` }}
/>
<span data-glitch-text className={`${TEXT} relative text-white`}>
{WORD}
</span>
</span>
))}
</span>
</span>
</div>
);
}
```Discovery vocabulary
Related by governed terms
An animated SVG signature effect that draws out text as if hand-written.
More from Vault