Split Text
Split any string into chars, words or lines and reveal it with rise, wave, scatter, blur or a human-paced typewriter.
A title sampled into thousands of canvas particles: the cursor parts them, they spring home, a click detonates and reforms the word.
Browser support · All evergreen browsers (Canvas 2D). Pauses off-screen; reduced motion renders static particles.
npx shadcn@latest add https://freedesignmd.com/r/particle-text.json1/**2 * ParticleText — text (or any canvas-drawable mark) sampled into particles.3 * The cursor pushes particles aside and they spring home; a click detonates4 * the word and it reforms. Particles keep a faint idle shimmer.5 *6 * Props7 * - 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 colour18 * bucket, DPR capped at 2. The loop pauses off-screen and when the tab is19 * hidden. Reduced motion: static particles, no explosion.20 * Accessibility: canvas is aria-hidden; a visually hidden heading/text holds21 * 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 canvas93 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 ring143 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 back183 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-deps270 271 return (272 <div273 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`;Split any string into chars, words or lines and reveal it with rise, wave, scatter, blur or a human-paced typewriter.
Cycle through words with a letter-level morph: shared letters glide into place, the rest blur away, the line stays centred.
A headline whose letters drift away from — or toward — your cursor and spring home, each on its own spring.