{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-particles",
  "type": "registry:block",
  "title": "Text Particles",
  "description": "A word printed in a few thousand dots that scatter from the pointer (harder the faster you move) and spring home one by one. Click to blow it apart on a curl-noise current; changing the text morphs the dots into the new word.",
  "docs": "Text Particles was added to public/vividsites/text-particles.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/text-particles",
  "categories": [
    "3d",
    "3d-objects"
  ],
  "files": [
    {
      "path": "components/vividsites/text-particles.html",
      "type": "registry:file",
      "target": "public/vividsites/text-particles.html",
      "content": "<!--\n  vividsites · text-particles\n  A word made of a few thousand dots. The pointer scatters them (the faster you move,\n  the harder they fly; moving dots flush with the accent), and each dot springs home\n  on its own stiffness so the word reforms organically. Click or tap to blow the whole\n  word apart; it drifts on a curl-noise current, then pulls itself back together.\n  Changing the text morphs the dots from one word into the next.\n\n  Customise (attributes, read at mount; all live through the JS API too):\n    data-text      the word or short phrase                  (default \"Scatter\")\n    data-density   dot pitch in px, 2 – 6 (smaller = more dots) (default 3)\n    data-force     pointer strength, 0 – 3                   (default 1)\n  CSS variables:\n    --vs-text-particles-ink     resting dots                 (#151413)\n    --vs-text-particles-accent  dots in motion               (#2a5cff)\n    --vs-text-particles-font    the face the word is set in   (700 1em \"Oswald\", sans-serif)\n    --vs-text-particles-height  component height             (100svh)\n  JS:\n    el.vsTextParticles.set({ text: 'Drift', accent: '#ff5a1f', force: 2, density: 2.5 })\n    el.vsTextParticles.explode()      el.vsTextParticles.destroy()\n-->\n<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Oswald:wght@700&display=swap\">\n<div class=\"vs-text-particles\" data-text=\"Scatter\" data-density=\"3\" data-force=\"1\" tabindex=\"0\" role=\"img\" aria-label=\"Scatter\">\n  <canvas class=\"vs-text-particles-canvas\"></canvas>\n  <span class=\"vs-text-particles-fallback\" aria-hidden=\"true\">Scatter</span>\n</div>\n\n<style>\n  .vs-text-particles {\n    --vs-text-particles-ink: #151413;\n    --vs-text-particles-accent: #2a5cff;\n    --vs-text-particles-font: 700 1em \"Oswald\", \"Arial Narrow\", sans-serif;\n    --vs-text-particles-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-text-particles-height); min-height: 320px; overflow: hidden;\n    cursor: crosshair; outline: none; touch-action: pan-y; -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none;\n  }\n  .vs-text-particles:focus-visible { box-shadow: inset 0 0 0 2px var(--vs-text-particles-accent); }\n  .vs-text-particles-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }\n  .vs-text-particles-fallback { position: absolute; inset: 0; display: none; place-items: center; font: var(--vs-text-particles-font); font-size: clamp(64px, 17vw, 240px);\n    letter-spacing: -.03em; color: var(--vs-text-particles-ink); }\n  .vs-text-particles[data-vs-state=\"nogl\"] .vs-text-particles-fallback { display: grid; }\n</style>\n\n<script type=\"module\">\nconst VERT = `attribute vec2 aPos; attribute float aHot, aAlpha; uniform vec2 uRes; uniform float uSize; varying float vHot, vAlpha;\n  void main(){ vHot = aHot; vAlpha = aAlpha; gl_PointSize = uSize * (1.0 + aHot * 0.35); gl_Position = vec4(aPos / uRes * vec2(2.0, -2.0) + vec2(-1.0, 1.0), 0.0, 1.0); }`;\nconst FRAG = `precision mediump float; uniform vec3 uInk, uAccent; varying float vHot, vAlpha;\n  void main(){ float d = length(gl_PointCoord - 0.5) * 2.0; float a = (1.0 - smoothstep(0.72, 1.0, d)) * vAlpha; if (a < 0.01) discard;\n    vec3 c = mix(uInk, uAccent, smoothstep(0.02, 0.5, vHot)); gl_FragColor = vec4(c * a, a); }`;\n\nconst hex = (h) => { const m = /^#?([\\da-f]{2})([\\da-f]{2})([\\da-f]{2})$/i.exec((h || '').trim()); return m ? [1, 2, 3].map((i) => parseInt(m[i], 16) / 255) : [0.08, 0.08, 0.07]; };\n// cheap 2-D curl of a sum of sines: divergence-free, so the drift swirls instead of clumping\nconst curl = (x, y, t) => {\n  const e = 0.001, f = (a, b) => Math.sin(a * 1.7 + t * 0.6) * Math.cos(b * 1.3 - t * 0.4) + Math.sin(a * 0.6 - b * 0.9 + t * 0.3) * 0.6;\n  return [(f(x, y + e) - f(x, y - e)) / (2 * e), -(f(x + e, y) - f(x - e, y)) / (2 * e)];\n};\n\nfunction mount(el) {\n  const canvas = el.querySelector('.vs-text-particles-canvas');\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const gl = canvas.getContext('webgl', { antialias: false, premultipliedAlpha: true, alpha: true });\n  if (!gl) { el.dataset.vsState = 'nogl'; return null; }\n  let prog, U = {}, A = {}, buf = {};\n  function initGL() {\n    const sh = (t, s) => { const o = gl.createShader(t); gl.shaderSource(o, s); gl.compileShader(o); return o; };\n    prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG)); gl.linkProgram(prog); gl.useProgram(prog);\n    ['uRes', 'uSize', 'uInk', 'uAccent'].forEach((n) => U[n] = gl.getUniformLocation(prog, n));\n    ['aPos', 'aHot', 'aAlpha'].forEach((n) => { A[n] = gl.getAttribLocation(prog, n); buf[n] = gl.createBuffer(); gl.enableVertexAttribArray(A[n]); });\n    gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);\n  }\n  initGL();\n\n  const S = { vis: true, raf: 0, last: 0, t: 0, w: 0, h: 0, dpr: 1, n: 0, pitch: 3, blast: 0, mx: -1e4, my: -1e4, pv: 0, lastM: null, force: 1, idle: 0,\n    ink: hex(getComputedStyle(el).getPropertyValue('--vs-text-particles-ink')), accent: hex(getComputedStyle(el).getPropertyValue('--vs-text-particles-accent')) };\n  let P = new Float32Array(0), V = new Float32Array(0), H = new Float32Array(0), K = new Float32Array(0), HOT = new Float32Array(0), AL = new Float32Array(0), DIE = new Uint8Array(0), DELAY = new Float32Array(0);\n\n  // sample the word into dot homes, on a jittered grid so it reads as a print, not a lattice\n  function sample() {\n    const w = S.w, h = S.h, text = el.dataset.text || 'Scatter';\n    const pitch = Math.max(2, Math.min(6, +el.dataset.density || 3)) * (w < 640 ? 0.8 : 1);\n    const off = document.createElement('canvas'); off.width = w; off.height = h;\n    const x = off.getContext('2d', { willReadFrequently: true });\n    const font = getComputedStyle(el).getPropertyValue('--vs-text-particles-font').trim() || '700 1em Oswald, sans-serif';\n    let size = Math.min(h * 0.4, 340);\n    x.font = font.replace(/[\\d.]+em/, size + 'px');\n    const tw = x.measureText(text).width, maxW = w * (w < 640 ? 0.88 : 0.72);\n    if (tw > maxW) { size *= maxW / tw; x.font = font.replace(/[\\d.]+em/, size + 'px'); }\n    x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillStyle = '#000';\n    if ('letterSpacing' in x) x.letterSpacing = (-0.01 * size) + 'px';\n    x.fillText(text, w / 2, h / 2 + size * 0.03);\n    const data = x.getImageData(0, 0, w, h).data, pts = [];\n    for (let yy = pitch / 2; yy < h; yy += pitch) for (let xx = pitch / 2; xx < w; xx += pitch) {\n      const jx = xx + (Math.random() - 0.5) * pitch * 0.35, jy = yy + (Math.random() - 0.5) * pitch * 0.35;\n      const i = ((jy | 0) * w + (jx | 0)) * 4 + 3; if (data[i] > 128) pts.push(jx, jy);\n    }\n    S.pitch = pitch;\n    // shuffle so a morph between words sends dots across the whole word, not in scanlines\n    for (let i = pts.length / 2 - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [pts[2 * i], pts[2 * j]] = [pts[2 * j], pts[2 * i]]; [pts[2 * i + 1], pts[2 * j + 1]] = [pts[2 * j + 1], pts[2 * i + 1]]; }\n    return new Float32Array(pts);\n  }\n  function retarget(first) {\n    const homes = sample(), n = homes.length / 2, old = S.n, m = Math.max(n, old);\n    const grow = (a, k) => { const b = new Float32Array(m * k); b.set(a.subarray(0, Math.min(a.length, m * k))); return b; };\n    P = grow(P, 2); V = grow(V, 2); H = new Float32Array(m * 2); K = grow(K, 1); HOT = grow(HOT, 1); AL = grow(AL, 1); DELAY = grow(DELAY, 1); DIE = new Uint8Array(m);\n    for (let i = 0; i < m; i++) {\n      if (i < n) { H[2 * i] = homes[2 * i]; H[2 * i + 1] = homes[2 * i + 1]; } else { const j = Math.floor(Math.random() * n); H[2 * i] = homes[2 * j]; H[2 * i + 1] = homes[2 * j + 1]; DIE[i] = 1; }\n      if (i >= old) {\n        if (first) { // the entrance: dots fall in from a loose cloud\n          const a = Math.random() * Math.PI * 2, r = Math.max(S.w, S.h) * (0.3 + Math.random() * 0.5);\n          P[2 * i] = S.w / 2 + Math.cos(a) * r; P[2 * i + 1] = S.h / 2 + Math.sin(a) * r * 0.6; AL[i] = 0;\n          DELAY[i] = reduced ? 0 : Math.random() * 0.6 + (H[2 * i] / S.w) * 0.5;\n        } else { const j = old ? Math.floor(Math.random() * old) : i; P[2 * i] = old ? P[2 * j] : H[2 * i]; P[2 * i + 1] = old ? P[2 * j + 1] : H[2 * i + 1]; AL[i] = 0; DELAY[i] = 0; }\n        K[i] = 0.75 + Math.random() * 0.5;\n      }\n      if (reduced && first) { P[2 * i] = H[2 * i]; P[2 * i + 1] = H[2 * i + 1]; }\n    }\n    S.n = m; S.live = n;\n    for (const k of ['aPos', 'aHot', 'aAlpha']) { gl.bindBuffer(gl.ARRAY_BUFFER, buf[k]); gl.bufferData(gl.ARRAY_BUFFER, (k === 'aPos' ? P : k === 'aHot' ? HOT : AL).byteLength, gl.DYNAMIC_DRAW); }\n  }\n\n  function resize() {\n    const w = el.clientWidth, h = el.clientHeight; if (!w || !h) return;\n    const changed = Math.abs(w - S.w) > 1 || Math.abs(h - S.h) > 1;\n    S.dpr = Math.min(devicePixelRatio, 2); canvas.width = Math.round(w * S.dpr); canvas.height = Math.round(h * S.dpr);\n    const first = S.w === 0; S.w = w; S.h = h;\n    if (changed) retarget(first);\n  }\n\n  const onMove = (e) => {\n    const r = el.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top, now = performance.now();\n    if (S.lastM) { const dt = Math.max(8, now - S.lastM.t); S.pv = Math.min(3, Math.hypot(x - S.lastM.x, y - S.lastM.y) / dt); }\n    S.lastM = { x, y, t: now }; S.mx = x; S.my = y; S.idle = 0;\n  };\n  const onLeave = () => { S.mx = S.my = -1e4; S.lastM = null; };\n  const onDown = (e) => { onMove(e); explode(); };\n  const onKey = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); explode(); } };\n  function explode() {\n    if (reduced) return;\n    S.blast = 1;\n    const cx = S.mx > -1e3 ? S.mx : S.w / 2, cy = S.my > -1e3 ? S.my : S.h / 2;\n    for (let i = 0; i < S.n; i++) {\n      const dx = P[2 * i] - cx, dy = P[2 * i + 1] - cy, d = Math.hypot(dx, dy) + 1, f = (260 + Math.random() * 520) * (0.55 + 14 / Math.sqrt(d));\n      V[2 * i] += dx / d * f + (Math.random() - 0.5) * 220; V[2 * i + 1] += dy / d * f + (Math.random() - 0.5) * 220;\n      DELAY[i] = 0.5 + Math.random() * 0.9;\n    }\n  }\n  el.addEventListener('pointermove', onMove); el.addEventListener('pointerleave', onLeave); el.addEventListener('pointerdown', onDown); el.addEventListener('keydown', onKey);\n\n  function frame(now) {\n    S.raf = 0; if (!S.vis) return;\n    const dt = Math.min(0.033, S.last ? (now - S.last) / 1000 : 0.016); S.last = now; S.t += dt; S.idle += dt;\n    S.pv *= Math.exp(-dt * 5);\n    S.blast = Math.max(0, S.blast - dt * 0.45);\n    const R = Math.min(S.w, S.h) * 0.13 * (0.8 + S.pv * 0.4), R2 = R * R, F = 8200 * S.force * (0.35 + S.pv), sc = 1 / Math.min(S.w, S.h) * 4;\n    let moving = 0, dying = 0;\n    for (let i = 0; i < S.n; i++) {\n      const ix = 2 * i, iy = ix + 1;\n      let px = P[ix], py = P[iy], vx = V[ix], vy = V[iy];\n      if (DELAY[i] > 0) { DELAY[i] -= dt; }\n      const home = DELAY[i] > 0 ? 0 : 1;\n      // spring home, each dot with its own stiffness\n      const k = 26 * K[i] * home, c = 2 * Math.sqrt(k) * 0.72 + (home ? 0 : 0.6);\n      vx += ((H[ix] - px) * k - vx * c) * dt; vy += ((H[iy] - py) * k - vy * c) * dt;\n      // the pointer: a soft radial push with a little swirl, stronger with pointer speed\n      const dx = px - S.mx, dy = py - S.my, d2 = dx * dx + dy * dy;\n      if (d2 < R2 && !reduced) { const d = Math.sqrt(d2) + 0.5, q = 1 - d / R, f = F * q * q / d; vx += (dx * f - dy * f * 0.35) * dt; vy += (dy * f + dx * f * 0.35) * dt; }\n      // while blown apart, ride a curl-noise current\n      if (!home) { const [cx, cy] = curl(px * sc, py * sc, S.t); vx += cx * 260 * dt; vy += cy * 260 * dt; vx *= 1 - dt * 2.2; vy *= 1 - dt * 2.2; }\n      px += vx * dt; py += vy * dt;\n      P[ix] = px; P[iy] = py; V[ix] = vx; V[iy] = vy;\n      const sp = Math.hypot(vx, vy); HOT[i] += (Math.min(1, sp / 300) - HOT[i]) * Math.min(1, dt * 6);\n      if (DIE[i]) { AL[i] = Math.max(0, AL[i] - dt * 1.5); if (AL[i] > 0) dying++; } else AL[i] = Math.min(1, AL[i] + dt * 2.2);\n      if (sp > 4 || Math.abs(H[ix] - px) > 0.5) moving++;\n    }\n    if (S.n > S.live && dying === 0) S.n = S.live; // drop the dots a shorter word no longer needs\n    gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);\n    gl.uniform2f(U.uRes, S.w, S.h); gl.uniform1f(U.uSize, S.pitch * 0.92 * S.dpr);\n    gl.uniform3fv(U.uInk, S.ink); gl.uniform3fv(U.uAccent, S.accent);\n    const up = (k, arr, size) => { gl.bindBuffer(gl.ARRAY_BUFFER, buf[k]); gl.bufferSubData(gl.ARRAY_BUFFER, 0, arr.subarray(0, S.n * size)); gl.vertexAttribPointer(A[k], size, gl.FLOAT, false, 0, 0); };\n    up('aPos', P, 2); up('aHot', HOT, 1); up('aAlpha', AL, 1);\n    gl.drawArrays(gl.POINTS, 0, S.n);\n    // sleep when every dot is home and the pointer is away\n    if (moving > 0 || S.mx > -1e3) loop(); else S.sleeping = true;\n  }\n  const loop = () => { S.sleeping = false; if (!S.raf && S.vis) S.raf = requestAnimationFrame(frame); };\n  el.addEventListener('pointerenter', () => loop());\n  el.addEventListener('pointermove', () => { if (S.sleeping) loop(); });\n\n  let rt; const ro = new ResizeObserver(() => { clearTimeout(rt); rt = setTimeout(() => { resize(); loop(); }, S.w ? 120 : 0); }); ro.observe(el);\n  const io = new IntersectionObserver(([e]) => { S.vis = e.isIntersecting; if (S.vis) { S.last = 0; loop(); } }); io.observe(el);\n  const onVis = () => { S.vis = !document.hidden; if (S.vis) { S.last = 0; loop(); } };\n  document.addEventListener('visibilitychange', onVis);\n  canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); cancelAnimationFrame(S.raf); S.raf = 0; });\n  canvas.addEventListener('webglcontextrestored', () => { initGL(); const n = S.n; S.n = 0; S.w = 0; resize(); loop(); });\n  const fontSpec = getComputedStyle(el).getPropertyValue('--vs-text-particles-font').trim().replace(/[\\d.]+em/, '100px');\n  // wait for the face (its stylesheet may still be in flight), then sample; resample if it lands late\n  const fontReady = () => !document.fonts || document.fonts.check(fontSpec, el.dataset.text);\n  const waitFont = () => new Promise((res) => { const t0 = performance.now(); const tick = () => { if (fontReady() || performance.now() - t0 > 3000) res(); else { document.fonts.load(fontSpec, el.dataset.text).catch(() => {}); setTimeout(tick, 60); } }; tick(); });\n  let sampledWithFont = false;\n  waitFont().then(() => { sampledWithFont = fontReady(); resize(); el.dataset.vsState = 'ready'; loop(); });\n  const onFonts = () => { if (!sampledWithFont && fontReady() && S.w) { sampledWithFont = true; retarget(false); loop(); } };\n  document.fonts && document.fonts.addEventListener('loadingdone', onFonts);\n\n  function set(o = {}) {\n    if (o.accent) { S.accent = hex(o.accent); el.style.setProperty('--vs-text-particles-accent', o.accent); }\n    if (o.ink) { S.ink = hex(o.ink); el.style.setProperty('--vs-text-particles-ink', o.ink); }\n    if (o.force != null) { S.force = +o.force; el.dataset.force = o.force; }\n    let re = false;\n    if (o.text != null) { el.dataset.text = o.text; el.setAttribute('aria-label', o.text); el.querySelector('.vs-text-particles-fallback').textContent = o.text; re = true; }\n    if (o.density != null) { el.dataset.density = o.density; re = true; }\n    if (re) (document.fonts ? document.fonts.load(fontSpec, el.dataset.text) : Promise.resolve()).then(() => { retarget(false); if (o.text != null && !reduced) for (let i = 0; i < S.n; i++) { V[2 * i] += (Math.random() - 0.5) * 500; V[2 * i + 1] += (Math.random() - 0.5) * 500; } loop(); });\n    loop();\n  }\n  S.force = +el.dataset.force || 1;\n  function destroy() {\n    cancelAnimationFrame(S.raf); S.vis = false; ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', onVis);\n    el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); el.removeEventListener('pointerdown', onDown); el.removeEventListener('keydown', onKey);\n    gl.getExtension('WEBGL_lose_context')?.loseContext(); delete el.vsTextParticles;\n  }\n  return { set, explode, destroy };\n}\n\ndocument.querySelectorAll('.vs-text-particles').forEach((el) => { if (!el.vsTextParticles) { const api = mount(el); if (api) el.vsTextParticles = api; } });\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D objects",
    "tags": [
      "text",
      "particles",
      "typography",
      "explode",
      "hover",
      "morph",
      "webgl"
    ],
    "tech": [
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#2a5cff",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/text-particles/index.html",
    "thumbnail": "https://vividsites.app/api/component/text-particles/thumb.jpg",
    "video": "https://vividsites.app/api/component/text-particles/thumb.mp4",
    "page": "https://vividsites.app/c/text-particles"
  }
}