← all topics

Particle CTA

WebGL

The particle text shader as a production-style call-to-action. Particles are sampled from a real <h2>'s rendered layout — font, wrapping and per-span color included — so the markup stays plain, accessible HTML and the canvas is pure decoration.

Ready to identify?

Start building with a free trial or speak to an expert to explore our Enterprise plan.

Controls
90px
3
0.055
2×
1

concepts

the font is the model

Geist Pixel draws every glyph on a 38-unit grid at 1000 units/em, anchored to the pen origin and baseline. In cells mode each filled font pixel becomes exactly one particle (or N×N when subdivided), sized from the grid — so at rest the canvas reproduces the font, and particle shape mirrors the variant.

sample the DOM, not a string

A Range per character gives the exact box the browser laid each glyph out in. Each one is redrawn offscreen with its computed font and color, then sampled — so a <span> in a different color becomes particles in that color.

progressive enhancement

The <h2> stays real text. It only drops to opacity: 0 once WebGL is drawing — not visibility: hidden, which would remove it from the accessibility tree. No JS, no WebGL, or reduced motion leaves the plain heading.

transparent canvas

The fragment shader outputs premultiplied color with blendFunc(ONE, ONE_MINUS_SRC_ALPHA), so particles composite cleanly over any background without dark or bright fringes.

costs nothing at rest

The loop stops when off-screen (IntersectionObserver), when the tab is hidden, and when particles settle. A pointer move wakes it. Physics runs on a fixed 60Hz step so 120Hz displays feel the same.

core pattern

<section aria-labelledby="cta">
  <canvas aria-hidden="true"></canvas>   <!-- decoration -->
  <h2 id="cta" style="opacity: 0">       <!-- still read aloud -->
    Ready to <span class="accent">identify?</span>
  </h2>
</section>

// per character: real layout box + computed style
range.setStart(node, i); range.setEnd(node, i + 1);
const r = range.getBoundingClientRect();
ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
ctx.fillStyle = cs.color;
ctx.fillText(ch, r.left - origin.left, baseline);