062

Particle Morph Text

£10 or the whole Vault, £99

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.

advanced
6 more details
webgl-shaderparticle-systemmorphingtext-animationpointer-effectsclick-toggle

This demo reads better at your own screen size than in the frame below:

Updated

About this effect

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.

Read the full effect overview

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.

Particle Morph Text - GSAP animation effect preview

What's included

10 items
  • 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

Perfect for

5 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

How it works

3 sections

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's own clock, so every tween on a uniform is current when the frame draws. A single linear progress value runs from 0 to 1 over 1.6 seconds. Each particle turns that into its own eased 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 kick value that eases back to zero; 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.

Difficulty Advanced
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this effect's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid effect

Purchase to unlock the code.

Buying Particle Morph Text opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse free effects

Or the whole library: the Vault, £99, one payment.

Documentation

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

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>:

Code snippet omitted: it ships with the download.

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

Code snippet omitted: it ships with the download.

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:

Code snippet omitted: it ships with the download.


Using It With Your Own Design

What the effect actually requires of your markup: a container with data-morph, and inside it a list with data-morph-words whose <li> items are the words, in the order they cycle. Each item's text is what gets sampled, so write it the way you want it drawn (FORM, not Form with a CSS text-transform; the sampler reads the text, not the computed style). data-label is optional and only feeds the morphchange event and your own chrome. Everything else is yours.

Image targets. Any item can carry data-image="logo.png" instead: the image is contain-fitted to the stage and its alpha channel is thresholded, so a transparent PNG or SVG-rendered-to-PNG of a logotype, a product cut-out or a symbol becomes a target the same way a word does. A JPEG with a white background will not work (every pixel is opaque, so the sampler fills the frame); cut the silhouette out first. If the image fails to load the item falls back to its text.

The font. Words are drawn with data-font, default 800 {size} Syne, where {size} is replaced by the fitted pixel size. The effect waits for document.fonts.load() on that font before sampling, so load your display face on the page (a <link> or @font-face) and name it here; a font that is not on the page falls back to whatever the browser substitutes. Words are fitted so the widest one spans data-fill of the container width.

What is only the demo's CSS and can be deleted: .morph__index, the .showcase-* shell, and the radial gradient on .morph's background (keep the ground dark, though; the particles are additive and need something to glow against). The stacked-headline styling of .morph__words is also yours to restyle, as long as it remains a real, visible headline.

Non-obvious things the effect depends on (keep these if you restyle):

  • The container must have a real size. The canvas is sized from the container's clientWidth/clientHeight, and the words are fitted to it, so .morph needs a height from somewhere: it is position: absolute; inset: 0 inside a pinned stage in the demo. Give it a height of 0 and you get a canvas of 0 and a word sampled into nothing.
  • The .morph__words list must stay in the document. It is the fallback for no JavaScript, no WebGL and reduced motion, and it is only hidden by html.gl (the head probe, before first paint) and .morph.is-live (the script, once a canvas exists; the canvas opens on drifting dust, which gathers into the first word once the display font has loaded). Never hide it under .has-js: a browser can have JavaScript and no WebGL.
  • touch-action: pan-y on .morph. On touch a horizontal drag is the wind; this is what keeps vertical page scrolling working from on top of it.
  • tabindex="0" on the root, or the arrow keys have nothing to land on.
  • The particle count is per instance and the targets are re-sampled on every resize, so one [data-morph] per page is the sensible limit.

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

Code snippet omitted: it ships with the download.

Example: a logotype silhouette between two words

Code snippet omitted: it ships with the download.

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


Inputs

  • Click or tap anywhere on the root: next word, with the gust spreading from the point you clicked. A press that travels more than a few pixels is a drag, not a tap.
  • Pointer move / touch drag: wind. Speed sets the push.
  • Arrow Left / Arrow Right with the root focused: previous / next word, immediately.
  • Idle: after data-pause with no input, the words cycle every data-auto.

A new input mid-morph does not wait: the particles are baked where they are and the next morph starts from there.


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.15.0 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.

Performance

One draw call per frame (plus one bake draw per morph). The cost is the vertex shader: three curl-noise evaluations per particle per frame, each six simplex noise calls, times the particle count. The loop is gated on an IntersectionObserver and does not run at all while the effect is off screen. Pixel ratio is capped at 2 (1.5 on touch devices).

To buy back frames on low-end hardware: lower data-count first. The vertex work is linear in it, and 20k still reads as a solid word at phone sizes. Nothing else moves the needle: the wind loop and the noise run for every particle whatever their amplitude.

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.