Particle Text Vortex

Seen on Original effect · credit Original effect built for this gallery (not a recreation of any specific site)
Open standalone ↗

How it works

Each word is rendered at large size into an offscreen <canvas>, then getImageData() collects every lit pixel. Those pixels — sampled at a stride of every 4px to cap the particle count near 6 000 — become the target positions that particles spring toward.

At the start of each cycle particles are seeded at random positions on a wide spiral around the canvas centre. A spring-damper model (velocity += (target - pos) * k; velocity *= damping) pulls each particle inward. A per-particle curl nudge — a slow sinusoidal perpendicular offset keyed to the particle's phase and performance.now() — keeps the settled shape from ever looking frozen.

The demo cycles through a list of words: after a settle dwell, all particles enter a dissolve phase where they drift outward along their current angle and fade, then the next word's targets are assigned and particles fly in from a fresh vortex scatter. A radial gradient drawn in 'lighter' composite mode adds a soft backlit glow around the letterforms while they form and hold.

Performance stays smooth by capping devicePixelRatio at 2, thinning sampled points to MAX_PARTICLES, and using a semi-transparent clear each frame (rgba(10,10,15,0.30)) to leave a motion-blur trail without a second layer.

This is an original effect built for this gallery — it is not a recreation of any particular website.

Code — copy & paste this whole file

<!doctype html>
<html lang="en">
<head>
  <link rel="icon" href="data:,">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Particle Text Vortex</title>
  <!-- a14y:begin head — machine-readable metadata; safe to delete when copying -->
  <meta name="description" content="Luminous particles spiral out of a vortex and settle into glowing typographic forms, then dissolve and reform the next word.">
  <link rel="canonical" href="https://fx.statico.io/effects/text-vortex.html">
  <link rel="alternate" type="text/markdown" href="https://fx.statico.io/effects/text-vortex.md" title="Particle Text Vortex (Markdown)">
  <meta property="og:type" content="website">
  <meta property="og:site_name" content="cool web fx">
  <meta property="og:title" content="Particle Text Vortex">
  <meta property="og:description" content="Luminous particles spiral out of a vortex and settle into glowing typographic forms, then dissolve and reform the next word.">
  <meta property="og:url" content="https://fx.statico.io/effects/text-vortex.html">
  <meta name="twitter:card" content="summary">
  <meta name="twitter:title" content="Particle Text Vortex">
  <meta name="twitter:description" content="Luminous particles spiral out of a vortex and settle into glowing typographic forms, then dissolve and reform the next word.">
  <script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"SoftwareSourceCode","@id":"https://fx.statico.io/effects/text-vortex.html#effect","name":"Particle Text Vortex","url":"https://fx.statico.io/effects/text-vortex.html","abstract":"Luminous particles spiral out of a vortex and settle into glowing typographic forms, then dissolve and reform the next word.","description":"Luminous particles spiral out of a vortex and settle into glowing typographic forms, then dissolve and reform the next word.","programmingLanguage":"HTML","codeSampleType":"full solution","runtimePlatform":"Web browser","codeRepository":"https://github.com/statico/cool-web-fx","license":"https://unlicense.org/","isBasedOn":"https://github.com/statico/cool-web-fx","mainEntityOfPage":"https://fx.statico.io/demos/text-vortex","dateModified":"2026-07-27T17:24:00-07:00","inLanguage":"en"},{"@type":"BreadcrumbList","@id":"https://fx.statico.io/effects/text-vortex.html#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://fx.statico.io/"},{"@type":"ListItem","position":2,"name":"Particle Text Vortex","item":"https://fx.statico.io/demos/text-vortex"},{"@type":"ListItem","position":3,"name":"Standalone source","item":"https://fx.statico.io/effects/text-vortex.html"}]}]}</script>
  <style>.a14y-doc{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}</style>
  <!-- a14y:end head -->
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  html, body {
    width: 100%;
    height: 100%;
    overflow: hidden;
    background: #0a0a0f;
  }
  canvas {
    display: block;
    width: 100%;
    height: 100%;
  }
</style>
</head>
<body>
  <!-- a14y:begin body — visually hidden description; safe to delete when copying -->
  <div class="a14y-doc">
    <h1>Particle Text Vortex</h1>
    <p>Luminous particles spiral out of a vortex and settle into glowing typographic forms, then dissolve and reform the next word.</p>
    <h2>How it works</h2>
    <p>Each word is rendered at large size into an offscreen &lt;canvas&gt;, then getImageData() collects every lit pixel. Those pixels — sampled at a stride of every 4px to cap the particle count near 6 000 — become the target positions that particles spring toward.</p>
    <p>At the start of each cycle particles are seeded at random positions on a wide spiral around the canvas centre. A spring-damper model (velocity += (target - pos) * k; velocity *= damping) pulls each particle inward. A per-particle curl nudge — a slow sinusoidal perpendicular offset keyed to the particle's phase and performance.now() — keeps the settled shape from ever looking frozen.</p>
    <p>The demo cycles through a list of words: after a settle dwell, all particles enter a dissolve phase where they drift outward along their current angle and fade, then the next word's targets are assigned and particles fly in from a fresh vortex scatter. A radial gradient drawn in 'lighter' composite mode adds a soft backlit glow around the letterforms while they form and hold.</p>
    <p>Performance stays smooth by capping devicePixelRatio at 2, thinning sampled points to MAX_PARTICLES, and using a semi-transparent clear each frame (rgba(10,10,15,0.30)) to leave a motion-blur trail without a second layer.</p>
    <p>This is an original effect built for this gallery — it is not a recreation of any particular website.</p>
    <h2>How to use this file</h2>
    <p>This is a complete, self-contained HTML document — copy it verbatim rather than reconstructing it. Markdown version: <a href="https://fx.statico.io/effects/text-vortex.md">https://fx.statico.io/effects/text-vortex.md</a>. Full writeup and live preview: <a href="https://fx.statico.io/demos/text-vortex">https://fx.statico.io/demos/text-vortex</a>.</p>
    <h2>Source and credit</h2>
    <p>Effect originally seen on Original effect (https://github.com/statico/cool-web-fx) — credit to Original effect built for this gallery (not a recreation of any specific site). This is an independent recreation built from scratch for educational use; no proprietary source code was copied. Released under the Unlicense.</p>
    <h2>More</h2>
    <p>Terms used above are defined in the <a href="https://fx.statico.io/glossary">glossary</a>. See also the <a href="https://fx.statico.io/sitemap.md">site map</a>, <a href="https://fx.statico.io/AGENTS.md">AGENTS.md</a> and <a href="https://fx.statico.io/llms.txt">llms.txt</a>.</p>
  </div>
  <!-- a14y:end body -->
<canvas id="c"></canvas>
<script>
/**
 * Particle Text Vortex
 * -----------------------------------------------------------------
 * Technique:
 *   1. Render a word to an offscreen canvas using a bold system font.
 *   2. Sample lit pixels from getImageData (every STEP px) → target points.
 *   3. Spawn particles scattered in a spiral vortex around the canvas center.
 *   4. Each particle springs toward its assigned target with eased velocity.
 *   5. On arrival, add gentle per-particle curl noise so the shape breathes.
 *   6. Periodically dissolve all particles back into a vortex and reform a
 *      new word (cycles through WORDS array).
 */

const WORDS        = ['vortex', 'particles', 'swirl', 'bloom'];
const STEP         = 4;          // pixel sampling stride on offscreen canvas
const MAX_PARTICLES= 6000;       // hard cap
const DPR_CAP      = 2;          // cap devicePixelRatio for GPU headroom
const FONT_RATIO   = 0.28;       // font size as fraction of canvas width
const SETTLE_TIME  = 3800;       // ms particles spend settled before dissolve
const DISSOLVE_TIME= 1200;       // ms for dissolve-out animation
const FORM_TIME    = 2000;       // ms for form-in animation

// ── Canvas setup ──────────────────────────────────────────────────────────────
const canvas = document.getElementById('c');
const ctx    = canvas.getContext('2d');
let W, H, dpr;

function resize() {
  dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP);
  W   = window.innerWidth;
  H   = window.innerHeight;
  canvas.width  = W * dpr;
  canvas.height = H * dpr;
  ctx.scale(dpr, dpr);
  // Rebuild particle targets whenever the window resizes
  rebuildTargets();
}

// ── Offscreen text sampling ───────────────────────────────────────────────────
/**
 * Draw `word` onto a hidden canvas, read its pixels, return an array of
 * {x, y} positions (in CSS-pixel space, centred on W×H) for each lit pixel.
 */
function sampleTextPixels(word) {
  const fontSize = Math.floor(Math.min(W * FONT_RATIO, H * 0.35));
  const font     = `900 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif`;

  // Measure text so we can size the offscreen canvas tightly
  const measure  = document.createElement('canvas');
  const mctx     = measure.getContext('2d');
  mctx.font      = font;
  const metrics  = mctx.measureText(word);
  const tw = Math.ceil(metrics.width) + 4;
  const th = fontSize + 8;

  measure.width  = tw;
  measure.height = th;
  mctx.font      = font;
  mctx.fillStyle = '#ffffff';
  mctx.textBaseline = 'top';
  mctx.fillText(word, 2, 4);

  const imageData = mctx.getImageData(0, 0, tw, th);
  const data      = imageData.data;

  const offsetX = (W - tw) / 2;
  const offsetY = (H - th) / 2;
  const points  = [];

  for (let py = 0; py < th; py += STEP) {
    for (let px = 0; px < tw; px += STEP) {
      const idx = (py * tw + px) * 4;
      // alpha channel > threshold means this pixel is part of the letter
      if (data[idx + 3] > 60) {
        points.push({
          x: offsetX + px,
          y: offsetY + py
        });
      }
    }
  }
  return points;
}

// ── Particle class ────────────────────────────────────────────────────────────
class Particle {
  constructor() {
    this.reset();
  }

  /** Place particle at a random spiral position centred on canvas */
  reset() {
    const angle  = Math.random() * Math.PI * 2;
    const radius = (Math.random() * 0.5 + 0.1) * Math.min(W, H) * 0.6;
    this.x  = W / 2 + Math.cos(angle) * radius;
    this.y  = H / 2 + Math.sin(angle) * radius;
    this.vx = 0;
    this.vy = 0;
    // Oscillator phase for curl noise
    this.phase = Math.random() * Math.PI * 2;
    this.speed = 0.6 + Math.random() * 0.8;  // individual spring multiplier
    // Target will be assigned externally
    this.tx = this.x;
    this.ty = this.y;
    // Visual
    this.size  = 0.8 + Math.random() * 0.9;
    this.alpha = 0;
    this.targetAlpha = 0.7 + Math.random() * 0.3;
    // dissolve / forming state
    this.settled = false;
    this.dissolving = false;
  }

  /** Assign this particle a new text-shape target */
  setTarget(tx, ty) {
    this.tx = tx;
    this.ty = ty;
    this.dissolved = false;
  }

  update(t, phase /* 'forming' | 'settled' | 'dissolving' */) {
    const SPRING = 0.045 * this.speed;
    const DAMP   = 0.82;

    if (phase === 'dissolving') {
      // Drift outward in a spiral while fading
      const angle  = Math.atan2(this.y - H / 2, this.x - W / 2);
      const spin   = angle + 0.04;
      const r      = Math.hypot(this.x - W / 2, this.y - H / 2);
      this.vx += (Math.cos(spin) * r * 0.006 + (this.x - W / 2) * 0.004);
      this.vy += (Math.sin(spin) * r * 0.006 + (this.y - H / 2) * 0.004);
      this.vx *= DAMP;
      this.vy *= DAMP;
      this.x  += this.vx;
      this.y  += this.vy;
      this.alpha *= 0.96;
      return;
    }

    // Curl noise: a slowly drifting perpendicular nudge keeps particles alive
    const curl  = Math.sin(t * 0.0007 + this.phase) * 0.28;
    const angle = Math.atan2(this.ty - H / 2, this.tx - W / 2);
    const curlX = -Math.sin(angle) * curl;
    const curlY =  Math.cos(angle) * curl;

    // Spring toward target
    this.vx += (this.tx - this.x) * SPRING + curlX;
    this.vy += (this.ty - this.y) * SPRING + curlY;
    this.vx *= DAMP;
    this.vy *= DAMP;
    this.x  += this.vx;
    this.y  += this.vy;

    // Fade in
    if (this.alpha < this.targetAlpha) {
      this.alpha = Math.min(this.targetAlpha, this.alpha + 0.018);
    }
  }

  draw(ctx) {
    if (this.alpha < 0.01) return;
    ctx.globalAlpha = this.alpha;
    ctx.fillStyle   = '#ffffff';
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
    ctx.fill();
  }
}

// ── State ─────────────────────────────────────────────────────────────────────
let particles   = [];
let wordIndex   = 0;
let textPoints  = [];
let animPhase   = 'forming';   // 'forming' | 'settled' | 'dissolving'
let phaseStart  = performance.now();
let animFrameId = null;

/** Build / rebuild targets for current word, (re)spawn particles as needed */
function rebuildTargets() {
  textPoints = sampleTextPixels(WORDS[wordIndex]);

  // Clamp to MAX_PARTICLES
  if (textPoints.length > MAX_PARTICLES) {
    // Thin evenly
    const step = textPoints.length / MAX_PARTICLES;
    const thinned = [];
    for (let i = 0; i < MAX_PARTICLES; i++) {
      thinned.push(textPoints[Math.floor(i * step)]);
    }
    textPoints = thinned;
  }

  const count = textPoints.length;

  // Resize particles array
  while (particles.length < count) particles.push(new Particle());
  particles.length = count;

  // Assign targets; reset position if dissolving or just starting
  for (let i = 0; i < count; i++) {
    particles[i].setTarget(textPoints[i].x, textPoints[i].y);
    if (animPhase !== 'settled') {
      // Scatter into vortex so they fly in
      const angle  = Math.random() * Math.PI * 2;
      const radius = (Math.random() * 0.4 + 0.2) * Math.min(W, H) * 0.7;
      particles[i].x = W / 2 + Math.cos(angle) * radius;
      particles[i].y = H / 2 + Math.sin(angle) * radius;
      particles[i].vx = 0;
      particles[i].vy = 0;
      particles[i].alpha = 0;
    }
  }
}

// ── Transition to next word ───────────────────────────────────────────────────
function startDissolve() {
  animPhase  = 'dissolving';
  phaseStart = performance.now();
}

function startNextWord() {
  wordIndex  = (wordIndex + 1) % WORDS.length;
  animPhase  = 'forming';
  phaseStart = performance.now();
  rebuildTargets();
}

// ── Render loop ───────────────────────────────────────────────────────────────
function tick(t) {
  animFrameId = requestAnimationFrame(tick);

  const elapsed = t - phaseStart;

  // Phase transitions
  if (animPhase === 'forming' && elapsed > FORM_TIME) {
    animPhase  = 'settled';
    phaseStart = t;
  } else if (animPhase === 'settled' && elapsed > SETTLE_TIME) {
    startDissolve();
  } else if (animPhase === 'dissolving' && elapsed > DISSOLVE_TIME) {
    startNextWord();
  }

  // Clear with slight motion-blur trail
  ctx.globalAlpha = 1;
  ctx.fillStyle   = 'rgba(10, 10, 15, 0.30)';
  ctx.fillRect(0, 0, W, H);

  // Update & draw each particle
  for (let i = 0; i < particles.length; i++) {
    particles[i].update(t, animPhase);
    particles[i].draw(ctx);
  }

  // Soft radial glow behind text using compositing
  if (animPhase !== 'dissolving') {
    const cx = W / 2;
    const cy = H / 2;
    const r  = Math.min(W, H) * 0.38;
    const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
    const glowAlpha = animPhase === 'forming'
      ? Math.min(1, elapsed / FORM_TIME) * 0.07
      : 0.07;
    grd.addColorStop(0,   `rgba(200, 195, 255, ${glowAlpha})`);
    grd.addColorStop(1,   'rgba(10, 10, 15, 0)');
    ctx.globalAlpha   = 1;
    ctx.globalCompositeOperation = 'lighter';
    ctx.fillStyle = grd;
    ctx.fillRect(0, 0, W, H);
    ctx.globalCompositeOperation = 'source-over';
  }
}

// ── Kick off ──────────────────────────────────────────────────────────────────
window.addEventListener('resize', resize);
resize();   // sets W, H, dpr, calls rebuildTargets()
animPhase  = 'forming';
phaseStart = performance.now();
requestAnimationFrame(tick);
</script>
</body>
</html>

Source & credit

Effect seen on https://github.com/statico/cool-web-fx — credit to Original effect built for this gallery (not a recreation of any specific site).

This is an independent recreation of the effect built from scratch for educational use. No proprietary source code was copied.