# Particle Morph Text

> A headline made of tens of thousands of GPU particles that dissolves in a gust and re-forms as the next word on click, while the cursor blows through the letters like wind. three.js, one shader, GSAP core.

Canonical: https://gsapvault.com/effects/particle-morph-text
Live demo: https://gsapvault.com/demos/particle-morph-text/index.html

| Property | Value |
|----------|-------|
| Type | effect |
| Tier | paid |
| Price | £5 |
| Difficulty | advanced |
| Plugins | Core GSAP only |
| Techniques | webgl-shader, particle-system, morphing, text-animation, pointer-effects, click-toggle |
| Uses Lenis | No |

## Overview

A WebGL headline built from forty thousand particles. Click, tap or press an arrow key and every particle leaves its letterform on a gust of curl noise, staggered outward from the point you clicked, and lands forming the next word. While they fly they grow, brighten and warm from the accent through white to amber, then cool as they settle; once settled the word keeps a faint ambient shimmer, never dead-still.

The cursor is wind. Move it through the word and the particles under it are blown out of the letters in a streaked trail, tumbling in the wake, then drift back on a fixed half-life. Whip it and the whole word is blown sideways and re-forms. On touch a finger drags the same wind and a slow breeze keeps the word alive between touches. Left alone it cycles through its words every few seconds.

The words come from a plain list in your HTML, sampled into particle targets with the display font once it has loaded, and any item can point at an image silhouette instead. Without JavaScript, without WebGL or under reduced motion that same list is shown as a stacked display headline.

## Features

- Tens of thousands of particles in one draw call: a single THREE.Points with one ShaderMaterial, additively blended, at 40k on desktop and 12k on touch
- A click kick: a ring of light and a shove leave the click point a beat ahead of the gust, on a GSAP expo.out ease
- The morph is a gust, not a crossfade: a divergence-free curl-noise field carries neighbouring particles together as eddies, with each particle's timing staggered by its distance from the click point
- Interruptible with no snap: a click mid-morph bakes every particle's current position into a float render target on the GPU and the next morph starts from exactly there
- One activity value drives three channels together: point size, alpha and a cosine palette from the accent through white to a hot colour, both colours set from data attributes
- The cursor is wind: the last twelve pointer samples each push with a Gaussian falloff and an age, so a stroke leaves a trail and a whip blows the whole word
- Targets sampled from your own DOM: a list of words in the display font after document.fonts.load, or a transparent PNG silhouette per item via data-image
- Click, tap, arrow keys and an idle auto-cycle that pauses after user input; fires a morphchange event for your own chrome
- Frame-rate independent throughout: the wind decays on a fixed half-life and the morph is a GSAP tween, so 30, 60 and 120Hz look identical
- Renders only while on screen, re-samples the words on resize, and disposes geometry, materials, render targets and the WebGL context itself on teardown

## Use Cases

- Agency and studio hero headlines that need one signature above-the-fold moment
- Product launch pages cycling through three or four positioning words
- Event and conference landing pages where the title is the whole first screen
- Portfolio intros that morph between a name, a discipline and a location
- Brand pages morphing a logotype silhouette into a word and back

## Vibe-Code Ready Setup

This effect includes `START-HERE-AI.md`, a product-specific copy-paste setup prompt for Cursor, Claude Code, ChatGPT, GitHub Copilot, Windsurf, and other coding assistants. It tells the assistant to inspect the existing stack, integrate the supplied files, preserve the design, scope selectors, retain accessibility and responsive behaviour, add framework-appropriate GSAP cleanup, and report what it tested.

[How AI-assisted setup works](https://gsapvault.com/vibe-coding)

## How It Works

Every particle carries two positions, where it started this morph and where it is going, plus a seed and a delay. The destination is a plain attribute the script writes from a sampled word: the word is drawn into a 2D canvas with the display font, its lit pixels indexed, and the particle positions picked from them. The start position lives in a float texture rather than an attribute. Before each morph the vertex shader is run once in a bake mode that writes every particle's current position to its own texel, so a morph started mid-flight continues from wherever the particles actually are, including their gust displacement, with no discontinuity.

The render loop runs on gsap.ticker, so GSAP is the clock and every tween on a uniform is current when the frame draws. GSAP tweens a single linear uProgress from 0 to 1 over 1.6 seconds. Each particle turns that into its own easeOutCubic inside a window offset by its delay, which is its normalised distance from the click point, so the gust spreads outward from the click. A click also sets a uKick uniform to 1 that GSAP eases back to 0 on expo.out; the shader reads it as a ring of light and a shove leaving the click point, so the ease shapes how fast the ring grows and how slowly it fades. A flight term that peaks mid-journey and is zero at both ends scales the curl-noise displacement, the point size and the palette position, which is what makes every in-flight channel settle to exactly nothing on arrival.

The wind is a uniform array of the last twelve pointer samples, each a position, a push vector from the pointer velocity and an age that halves every 0.35 seconds on the CPU. The vertex shader averages the pushes by Gaussian distance, applies them, and adds a second curl term scaled by the push so blown particles tumble rather than slide. The colour is a cosine palette whose constants are solved on init so it passes through the rest colour, white and the hot colour exactly.

## Integration Preview

How this effect integrates into a page. The full documentation (examples, events, programmatic API, customization guide) ships with the download.

### Quick Start

**1. Add to your HTML `<head>`:**

```html
<link rel="stylesheet" href="assets/style.css">
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@800&display=swap" rel="stylesheet">
<script>
  (function () {
    try {
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      var c = document.createElement('canvas');
      var gl = c.getContext('webgl2') || c.getContext('webgl');
      if (!gl) return;
      var lose = gl.getExtension('WEBGL_lose_context');
      if (lose) lose.loseContext();
      document.documentElement.classList.add('gl');
    } catch (e) {}
  })();
</script>
```

The inline script is not optional. The word list is the fallback for no
JavaScript, no WebGL and reduced motion, and without this it paints as a
plain headline for the half second it takes three.js to arrive, then gets
swapped for particles. The probe runs before first paint, stamps `html.gl`
when a canvas is coming, and the stylesheet hides the list under that class.
No JavaScript, no class, headline shows; and the effect removes the class
again if it cannot build a renderer after all.

**2. Add to your `<body>`:**

```html
<div class="morph" data-morph tabindex="0" role="group" aria-label="Headline, one word at a time">
  <ul class="morph__words" data-morph-words>
    <li data-label="Form">FORM</li>
    <li data-label="Flow">FLOW</li>
    <li data-label="Flux">FLUX</li>
  </ul>
</div>
```

**3. Add before the closing `</body>` tag:**

```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.min.js"
  }
}
</script>
<script type="module">
  import * as THREE from 'three';
  window.THREE = THREE;
  const effect = document.createElement('script');
  effect.src = 'assets/script.js';
  document.body.appendChild(effect);
</script>
```

The script reads the list, samples each word into particle targets once the
font has loaded, drops a canvas in front of the list, and hides the list once
the particles are actually drawing.

**Why the import map rather than a plain `<script src>` for three.js:** three
ships as ES modules only, and its old UMD build logs a deprecation warning on
every page load. The shim above imports it as a module, puts it on `window`,
and then loads `assets/script.js` as an ordinary script, so the effect itself
stays a plain file you can drop into any build (or none).

**Already using three.js as a module?** Then skip the shim, and just make sure
`window.THREE` is set before `assets/script.js` runs:

```javascript
import * as THREE from 'three';
window.THREE = THREE;
```

---

### Options

All set on the `[data-morph]` element.

| Attribute | Values | Default | Description |
|---|---|---|---|
| `data-count` | integer | `40000` (fine pointer) / `12000` (coarse) | Particle count. The first lever to lower on slow hardware. Five display letters at 1200px read solid at 40k; below ~15k on desktop the word goes grainy. |
| `data-size` | number | `2.6` | Base point size in CSS pixels at rest. In flight a particle grows to 2.4x this. |
| `data-gust` | number | `105` | Curl-noise amplitude mid-flight, in pixels. Below ~40 the morph reads as a straight crossfade of positions; above ~160 the word is lost entirely for the middle third of the flight. |
| `data-idle` | number | `1.6` | Ambient drift at rest, in pixels. `0` makes a settled word dead-still. |
| `data-duration` | seconds | `1.6` | Morph duration. Linear on the whole; the easing is per particle. |
| `data-wind` | number | `0.06` | How hard the pointer blows: push in pixels = pointer speed (px/s) x this, clamped at 180px. `0` turns the wind off. |
| `data-wind-radius` | number | `110` | Falloff radius of each wind sample, in pixels. |
| `data-auto` | ms | `5000` | Idle auto-cycle interval. `0` turns it off. |
| `data-pause` | ms | `8000` | How long the auto-cycle waits after any user input before resuming. |
| `data-fill` | number | `0.84` | Fraction of the container width the widest word is fitted to. |
| `data-font` | CSS font shorthand with `{size}` | `800 {size} Syne` | Font the words are sampled with. Must be loaded on the page. |
| `data-color` | hex | `#22d3ee` | Rest colour (the site's cyan). |
| `data-color-hot` | hex | `#ffc780` | Colour at peak activity. The palette runs rest → white → hot, so in flight particles pass through white on the way to this. |

#### Example: a calmer headline in a brand colour, no auto-cycle

```html
<div class="morph" data-morph tabindex="0"
     data-color="#ff6b35" data-color-hot="#ffe3c2"
     data-gust="60" data-wind="0.03" data-auto="0">
  <ul class="morph__words" data-morph-words>
    <li>DESIGN</li>
    <li>BUILD</li>
  </ul>
</div>
```

#### Example: a logotype silhouette between two words

```html
<ul class="morph__words" data-morph-words>
  <li>ACME</li>
  <li data-image="img/acme-mark.png" data-label="Acme mark"><img src="img/acme-mark.png" alt="Acme mark"></li>
  <li>2027</li>
</ul>
```

The `<img>` inside the item is optional and only there so the fallback
headline shows the mark too; the effect samples from `data-image`.

---

### Accessibility

- **Keyboard**: the root is focusable; left and right arrows step the words.
  Keyboard changes start the morph immediately.
- **Screen readers**: the canvas is not announced. Announce the current word
  yourself from a live region fed by `morphchange`; the demo page shows the
  pattern.
- **Reduced motion**: the particles never start. The list stays exactly as it
  is, a stacked display headline of every word, with no canvas created.
- **Without JavaScript, or without WebGL**: the same headline, for the same
  reason. One fallback, three failure modes, and it is never hidden until the
  particles are actually drawing.
- **The auto-cycle is a real decision.** A headline that changes on its own
  every five seconds is fine above the fold and distracting next to body copy.
  `data-auto="0"` turns it off.

---

### Dependencies

| Dependency | Version | Required |
|---|---|---|
| three.js | 0.180.0 | Yes, as an ES module (see Quick Start) |
| GSAP core | 3.14.2 | Yes: the frame clock (`gsap.ticker`), the morph tween, the click kick, matchMedia branching and teardown |

No GSAP plugins. The render loop runs on `gsap.ticker`, so GSAP is the
clock: every tween on a uniform has been advanced before the frame renders,
lag smoothing absorbs a backgrounded tab, and there is one `requestAnimationFrame`
on the page however many effects share it. GSAP owns the two played values,
the linear `uProgress` of each morph (which every particle eases on its own
in the shader) and the click kick `uKick`, eased back to zero on `expo.out`.
The continuous values (wind, idle drift, time) are integrated in the ticker
callback against real elapsed time.

Everything the effect uses (`WebGLRenderer`, `OrthographicCamera`,
`BufferGeometry`, `Points`, `ShaderMaterial`, `WebGLRenderTarget`) is
long-stable three.js API, so pinning to a different version is a one-line
change in the import map.

---

### Browser Support

Anything with WebGL, which is every current browser. Support is **probed**
before three.js is asked for a renderer, deliberately: three logs its own
failure to the console, as errors, several times over, before it throws, so
catching the exception would hide nothing and a visitor with WebGL disabled
would get a console full of red on a page that had quietly fallen back. Probed
first, that visitor simply gets the headline and a clean console.

The position bake renders to a float texture where the browser can
(`EXT_color_buffer_float`, every current browser) and a half-float one
otherwise, which is half-pixel precision at the edge of a 1200px stage.

Import maps are supported everywhere current. In a browser old enough to lack
them the module never runs, `window.THREE` is never set, and the headline is
again what shows.

## What You Get

- `index.html`: working demo page
- `assets/script.js`: commented, readable source
- `assets/style.css`: effect styles
- `README.md`: full documentation with examples and framework integration notes
- `START-HERE-AI.md`: product-specific copy-paste prompt for AI-assisted setup
- `LICENSE.txt`: standard license terms
- Lifetime updates: re-download anytime from your library

## Get the Code

This is a premium effect. The standard license costs £5 one-time and covers unlimited personal and commercial projects with no attribution required. The only restrictions: no redistribution of the code itself and no competing effect libraries.

- [Buy Particle Morph Text](https://gsapvault.com/effects/particle-morph-text)
- [Effects & Templates Vault (£39 one-time, best value): every current and future effect and template](https://gsapvault.com/effects)

## Judge the Code Quality First

These related effects are free with complete source published, written to the same production standard (cleanup functions, reduced-motion support, framework-agnostic):

- [Typewriter Text](https://gsapvault.com/effects/typewriter-text.md): A sharp terminal-style typewriter that types, holds, accelerates through deletion, and cycles to the next phrase in sync with cursor and progress signals.
- [Scroll Text Highlight](https://gsapvault.com/effects/scroll-text-highlight.md): A scrubbed orange-to-lime reading front lifts each active word before completed copy settles to white and unread copy remains ghosted.

---

From [GSAP Vault](https://gsapvault.com): production-ready GSAP animation effects. Full catalog for agents: https://gsapvault.com/llms-full.txt
