Particle Text

A title sampled into thousands of canvas particles: the cursor parts them, they spring home, a click detonates and reforms the word.

  • Text Effects
  • Music / Events
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (Canvas 2D). Pauses off-screen; reduced motion renders static particles.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/particle-text.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/text/particle-text.tsx
1/**
2 * ParticleText — text (or any canvas-drawable mark) sampled into particles.
3 * The cursor pushes particles aside and they spring home; a click detonates
4 * the word and it reforms. Particles keep a faint idle shimmer.
5 *
6 * Props
7 * - text: string Word or short title. Use "\n" for two lines.
8 * - font?: string Canvas font weight + family. Default 800 + system display stack.
9 * - colors?: string[] Particle palette, picked by x position. Default warm gradient.
10 * - gap?: number Sampling step in CSS px (smaller = more particles). Default auto by size.
11 * - radius?: number Cursor influence radius in px. Default 90.
12 * - draw?: (ctx, w, h) => void Draw your own mark (logo) in white instead of text.
13 * - label?: string Accessible text. Default `text`.
14 * - className / style Size the box; the canvas fills it (min-height 240px).
15 *
16 * Physics: per particle spring to home (k 0.06/frame-normalised, damping 0.86)
17 * plus a radial push from the pointer. Typed arrays, one draw call per colour
18 * bucket, DPR capped at 2. The loop pauses off-screen and when the tab is
19 * hidden. Reduced motion: static particles, no explosion.
20 * Accessibility: canvas is aria-hidden; a visually hidden heading/text holds
21 * the label. Click / Enter / Space on the focusable stage trigger the burst.
22 * Browser support: all evergreen browsers (Canvas 2D).
23 */
24import { useEffect, useRef } from "react";
25import type { CSSProperties } from "react";
26
27export interface ParticleTextProps {
28 text: string;
29 font?: string;
30 colors?: string[];
31 gap?: number;
32 radius?: number;
33 draw?: (ctx: CanvasRenderingContext2D, w: number, h: number) => void;
34 label?: string;
35 className?: string;
36 style?: CSSProperties;
37}
38
39const DISPLAY = '800 100px "SF Pro Display", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
40
41export function ParticleText({
42 text,
43 font = DISPLAY,
44 colors = ["#FFD166", "#FF8A5B", "#FF4D8D", "#B66DFF", "#5B8CFF"],
45 gap,
46 radius = 90,
47 draw,
48 label,
49 className,
50 style,
51}: ParticleTextProps) {
52 const wrap = useRef<HTMLDivElement>(null);
53 const cv = useRef<HTMLCanvasElement>(null);
54 const burstRef = useRef<() => void>(() => {});
55
56 useEffect(() => {
57 const host = wrap.current;
58 const canvas = cv.current;
59 if (!host || !canvas) return;
60 const ctx = canvas.getContext("2d");
61 if (!ctx) return;
62 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
63
64 let W = 0;
65 let H = 0;
66 let dpr = 1;
67 let n = 0;
68 let hx = new Float32Array(0);
69 let hy = new Float32Array(0);
70 let x = new Float32Array(0);
71 let y = new Float32Array(0);
72 let vx = new Float32Array(0);
73 let vy = new Float32Array(0);
74 let col = new Uint8Array(0);
75 let sz = new Float32Array(0);
76 let ph = new Float32Array(0);
77 let px = -1e5;
78 let py = -1e5;
79 let raf = 0;
80 let visible = true;
81 let lastT = 0;
82 let first = true;
83 let burstAt = -1e9;
84
85 const sample = () => {
86 const r = host.getBoundingClientRect();
87 W = Math.max(1, Math.round(r.width));
88 H = Math.max(1, Math.round(r.height));
89 dpr = Math.min(2, window.devicePixelRatio || 1);
90 canvas.width = Math.round(W * dpr);
91 canvas.height = Math.round(H * dpr);
92 // rasterise mark at 1x into an offscreen canvas
93 const off = document.createElement("canvas");
94 off.width = W;
95 off.height = H;
96 const o = off.getContext("2d", { willReadFrequently: true });
97 if (!o) return;
98 o.fillStyle = "#fff";
99 if (draw) draw(o, W, H);
100 else {
101 const lines = text.split("\n");
102 o.font = font;
103 const widest = Math.max(...lines.map((l) => o.measureText(l).width));
104 const sizeByW = (100 * W * 0.9) / widest;
105 const sizeByH = (H * 0.86) / (lines.length * 0.92);
106 const fs = Math.min(sizeByW, sizeByH);
107 o.font = font.replace(/\d+px/, `${fs.toFixed(1)}px`);
108 o.textAlign = "center";
109 o.textBaseline = "middle";
110 const lh = fs * 0.92;
111 lines.forEach((l, i) => o.fillText(l, W / 2, H / 2 + (i - (lines.length - 1) / 2) * lh));
112 }
113 const data = o.getImageData(0, 0, W, H).data;
114 const step = gap ?? Math.max(3, Math.round(Math.sqrt((W * H) / 14000)));
115 const pts: number[] = [];
116 for (let yy = 0; yy < H; yy += step) {
117 for (let xx = (yy / step) % 2 ? step / 2 : 0; xx < W; xx += step) {
118 const i = ((yy | 0) * W + (xx | 0)) * 4 + 3;
119 if (data[i] > 128) pts.push(xx, yy);
120 }
121 }
122 const prevN = n;
123 const ox = x;
124 const oy = y;
125 n = pts.length / 2;
126 hx = new Float32Array(n);
127 hy = new Float32Array(n);
128 const nx = new Float32Array(n);
129 const ny = new Float32Array(n);
130 vx = new Float32Array(n);
131 vy = new Float32Array(n);
132 col = new Uint8Array(n);
133 sz = new Float32Array(n);
134 ph = new Float32Array(n);
135 for (let i = 0; i < n; i++) {
136 hx[i] = pts[i * 2];
137 hy[i] = pts[i * 2 + 1];
138 if (i < prevN) {
139 nx[i] = ox[i];
140 ny[i] = oy[i];
141 } else if (first && !reduced) {
142 // initial assembly: particles drift in from a wide ring
143 const a = Math.random() * Math.PI * 2;
144 const d = Math.max(W, H) * (0.6 + Math.random() * 0.5);
145 nx[i] = W / 2 + Math.cos(a) * d;
146 ny[i] = H / 2 + Math.sin(a) * d;
147 } else {
148 nx[i] = hx[i];
149 ny[i] = hy[i];
150 }
151 const t = hx[i] / W + (Math.random() - 0.5) * 0.18;
152 col[i] = Math.max(0, Math.min(colors.length - 1, Math.floor(t * colors.length)));
153 sz[i] = step * (0.42 + Math.random() * 0.22);
154 ph[i] = Math.random() * Math.PI * 2;
155 }
156 x = nx;
157 y = ny;
158 first = false;
159 };
160
161 const render = (t: number) => {
162 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
163 ctx.clearRect(0, 0, W, H);
164 for (let c = 0; c < colors.length; c++) {
165 ctx.fillStyle = colors[c];
166 ctx.beginPath();
167 for (let i = 0; i < n; i++) {
168 if (col[i] !== c) continue;
169 const s = sz[i] * (reduced ? 1 : 0.9 + 0.12 * Math.sin(t * 0.002 + ph[i]));
170 ctx.rect(x[i] - s / 2, y[i] - s / 2, s, s);
171 }
172 ctx.fill();
173 }
174 };
175
176 const tick = (t: number) => {
177 raf = 0;
178 const f = Math.min(2, (t - lastT) / 16.667 || 1);
179 lastT = t;
180 const R = radius;
181 const R2 = R * R;
182 // after a burst the spring relaxes and air drag drops, then both ease back
183 const since = t - burstAt;
184 const relax = since < 1400 ? Math.min(1, Math.max(0.02, (since - 380) / 1000)) : 1;
185 const k = 0.06 * f * relax * relax;
186 const damp = Math.pow(0.86 + 0.06 * (1 - relax), f);
187 for (let i = 0; i < n; i++) {
188 const dx = x[i] - px;
189 const dy = y[i] - py;
190 const d2 = dx * dx + dy * dy;
191 if (d2 < R2) {
192 const d = Math.sqrt(d2) || 1;
193 const push = (1 - d / R) * 7 * f;
194 vx[i] += (dx / d) * push;
195 vy[i] += (dy / d) * push;
196 }
197 vx[i] = (vx[i] + (hx[i] - x[i]) * k) * damp;
198 vy[i] = (vy[i] + (hy[i] - y[i]) * k) * damp;
199 x[i] += vx[i] * f;
200 y[i] += vy[i] * f;
201 }
202 render(t);
203 if (visible && !document.hidden) raf = requestAnimationFrame(tick);
204 };
205 const start = () => {
206 if (!raf && visible && !reduced) {
207 lastT = performance.now();
208 raf = requestAnimationFrame(tick);
209 }
210 };
211
212 sample();
213 if (reduced) render(0);
214 else start();
215
216 const ro = new ResizeObserver(() => {
217 const r = host.getBoundingClientRect();
218 if (Math.round(r.width) === W && Math.round(r.height) === H) return;
219 sample();
220 if (reduced) render(0);
221 });
222 ro.observe(host);
223 document.fonts?.ready.then(() => {
224 sample();
225 if (reduced) render(0);
226 }).catch(() => {});
227
228 const io = new IntersectionObserver((es) => {
229 visible = es.some((e) => e.isIntersecting);
230 if (visible) start();
231 });
232 io.observe(host);
233 const onVis = () => !document.hidden && start();
234 document.addEventListener("visibilitychange", onVis);
235
236 const onMove = (e: PointerEvent) => {
237 const r = canvas.getBoundingClientRect();
238 px = e.clientX - r.left;
239 py = e.clientY - r.top;
240 };
241 const onLeave = () => {
242 px = py = -1e5;
243 };
244 burstRef.current = () => {
245 if (reduced) return;
246 const cx = px > -1e4 ? px : W / 2;
247 const cy = py > -1e4 ? py : H / 2;
248 for (let i = 0; i < n; i++) {
249 const dx = x[i] - cx;
250 const dy = y[i] - cy;
251 const d = Math.hypot(dx, dy) || 1;
252 const p = 6 + Math.random() * 9 + Math.min(10, 1600 / (d + 60));
253 vx[i] += (dx / d) * p + (Math.random() - 0.5) * 8;
254 vy[i] += (dy / d) * p + (Math.random() - 0.5) * 8;
255 }
256 burstAt = performance.now();
257 start();
258 };
259 host.addEventListener("pointermove", onMove);
260 host.addEventListener("pointerleave", onLeave);
261 return () => {
262 cancelAnimationFrame(raf);
263 ro.disconnect();
264 io.disconnect();
265 document.removeEventListener("visibilitychange", onVis);
266 host.removeEventListener("pointermove", onMove);
267 host.removeEventListener("pointerleave", onLeave);
268 };
269 }, [text, font, colors.join(","), gap, radius, draw]); // eslint-disable-line react-hooks/exhaustive-deps
270
271 return (
272 <div
273 ref={wrap}
274 className={`fd-pt${className ? " " + className : ""}`}
275 style={style}
276 role="button"
277 tabIndex={0}
278 aria-label={`${label ?? text.replace(/\n/g, " ")} — press to scatter`}
279 onClick={() => burstRef.current()}
280 onKeyDown={(e) => {
281 if (e.key === "Enter" || e.key === " ") {
282 e.preventDefault();
283 burstRef.current();
284 }
285 }}
286 >
287 <style>{css}</style>
288 <canvas ref={cv} className="fd-pt-cv" aria-hidden="true" />
289 </div>
290 );
291}
292
293const css = `
294.fd-pt,.fd-pt *{box-sizing:border-box}
295.fd-pt{position:relative;display:block;margin:0;padding:0;min-height:240px;width:100%;cursor:pointer;touch-action:pan-y;-webkit-tap-highlight-color:transparent;outline:0;border-radius:20px}
296.fd-pt:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px}
297.fd-pt-cv{position:absolute;inset:0;display:block;width:100%;height:100%;margin:0;padding:0}
298`;
299
300/* ───────────────────────────── demo ───────────────────────────── */
301
302const LINEUP = [
303 ["Fri 17 Jul", "Nilsa Varga", "Oberon Tide", "Kasai Lanes", "The Halyards"],
304 ["Sat 18 Jul", "Mireille Ochoa", "Night Ferries", "Glasshouse", "Tomo & Ilse"],
305 ["Sun 19 Jul", "Ayo Bankole", "Paper Moths", "Dunes Club", "Vela Sound"],
306];
307
308export function Demo() {
309 return (
310 <div className="fd-pt-demo">
311 <style>{demoCss}</style>
312 <div className="fd-pt-demo-sky" aria-hidden="true" />
313 <header className="fd-pt-demo-bar">
314 <span className="fd-pt-demo-mark">SFF/27</span>
315 <nav className="fd-pt-demo-nav" aria-label="Primary">
316 <a href="#lineup">Line-up</a><a href="#tickets">Tickets</a><a href="#camping">Camping</a><a href="#faq">FAQ</a>
317 </nav>
318 <a className="fd-pt-demo-btn" href="#tickets">Get tickets</a>
319 </header>
320 <main className="fd-pt-demo-main">
321 <p className="fd-pt-demo-eyebrow">17 — 19 July 2027 · Lake Alster, Hamburg</p>
322 <h1 className="fd-pt-demo-sr">Saltwater Festival</h1>
323 <ParticleText text={"SALT\nWATER"} label="Saltwater" className="fd-pt-demo-stage" />
324 <p className="fd-pt-demo-hint" aria-hidden="true">Move through the letters · click to scatter</p>
325 <div className="fd-pt-demo-lineup" id="lineup">
326 {LINEUP.map(([day, ...acts]) => (
327 <section key={day} className="fd-pt-demo-day" aria-label={day}>
328 <h2>{day}</h2>
329 <p>{acts.join(" · ")}</p>
330 </section>
331 ))}
332 </div>
333 </main>
334 </div>
335 );
336}
337
338const demoCss = `
339.fd-pt-demo,.fd-pt-demo *,.fd-pt-demo *::before,.fd-pt-demo *::after{box-sizing:border-box}
340.fd-pt-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;--d-disp:"SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;
341 position:relative;overflow:hidden;margin:0;padding:0;min-height:100vh;background:#0B0714;color:#F7F1FF;font:400 15px/1.5 var(--d-font);-webkit-font-smoothing:antialiased}
342.fd-pt-demo-sky{position:absolute;inset:0;pointer-events:none;
343 background:radial-gradient(60% 50% at 20% 10%,rgba(182,109,255,.28),transparent 70%),radial-gradient(50% 50% at 85% 30%,rgba(255,77,141,.2),transparent 70%),radial-gradient(70% 50% at 50% 110%,rgba(91,140,255,.25),transparent 70%)}
344.fd-pt-demo-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
345.fd-pt-demo-bar{position:relative;display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0;padding:20px clamp(16px,4vw,48px)}
346.fd-pt-demo-mark{margin:0;font:800 17px/1 var(--d-disp);letter-spacing:.02em}
347.fd-pt-demo-nav{display:flex;gap:28px;margin:0}
348.fd-pt-demo-nav a{margin:0;padding:6px 0;color:rgba(247,241,255,.65);font:500 15px/1 var(--d-font);text-decoration:none}
349.fd-pt-demo-nav a:hover{color:#F7F1FF}
350.fd-pt-demo-btn{display:inline-flex;align-items:center;height:40px;margin:0;padding:0 18px;border-radius:9999px;background:#FFD166;color:#1B1206;font:600 14px/1 var(--d-font);text-decoration:none;
351 transition:transform 120ms cubic-bezier(.22,1,.36,1)}
352.fd-pt-demo-btn:active{transform:scale(.96)}
353.fd-pt-demo a:focus-visible{outline:2px solid rgba(10,132,255,.8);outline-offset:2px}
354.fd-pt-demo-main{position:relative;max-width:1280px;margin:0 auto;padding:clamp(12px,3vh,32px) clamp(16px,4vw,48px) 40px;text-align:center}
355.fd-pt-demo-eyebrow{margin:0;color:rgba(247,241,255,.7);font:500 13px/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.08em;text-transform:uppercase}
356.fd-pt-demo-stage{height:clamp(260px,52vh,520px);margin:12px 0 0}
357.fd-pt-demo-hint{margin:6px 0 0;color:rgba(247,241,255,.45);font:500 12px/1 var(--d-font);letter-spacing:.02em}
358.fd-pt-demo-lineup{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:clamp(24px,4vh,40px) 0 0;text-align:left}
359.fd-pt-demo-day{margin:0;padding:16px 18px;border-radius:20px;background:rgba(247,241,255,.05);box-shadow:inset 0 0 0 1px rgba(247,241,255,.08)}
360.fd-pt-demo-day h2{margin:0 0 6px;color:#FFD166;font:600 13px/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.06em;text-transform:uppercase}
361.fd-pt-demo-day p{margin:0;color:#F7F1FF;font:500 15px/1.45 var(--d-font);letter-spacing:-.01em}
362@media (max-width:760px){.fd-pt-demo-nav{display:none}.fd-pt-demo-lineup{grid-template-columns:1fr}.fd-pt-demo-stage{height:300px}}
363`;

More Text Effects. Same file contract.

Split Text

Split any string into chars, words or lines and reveal it with rise, wave, scatter, blur or a human-paced typewriter.

Text Morph

Cycle through words with a letter-level morph: shared letters glide into place, the rest blur away, the line stays centred.

Text Repel

A headline whose letters drift away from — or toward — your cursor and spring home, each on its own spring.