webdevpuneet.com

Splitting.js CSS Stagger — Per-Character Text Animation

Splitting.js CSS Stagger · Text Animation · Plain HTML, CSS & JS · Live preview

Copy & paste

Source Code

Requires
<div class="scs-wrap">
  <span class="scs-tag">splitting.js · css custom properties</span>

  <h1 class="scs-title fx-wave" id="scsTitle" data-splitting>Motion, one character at a time.</h1>

  <p class="scs-note">Splitting.js writes <code>--char-index</code> onto every character. The animation itself is 100% CSS.</p>

  <div class="scs-fx" id="scsFx">
    <button class="scs-chip is-on" data-fx="fx-wave">Wave</button>
    <button class="scs-chip" data-fx="fx-flip">Flip</button>
    <button class="scs-chip" data-fx="fx-blur">Blur</button>
    <button class="scs-chip" data-fx="fx-spectrum">Spectrum</button>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Zero JS animation
Splitting only writes custom properties — CSS does every effect.
calc-based stagger
animation-delay derived from --char-index, no timeline object.
Length-proof gradients
char-index over char-total gives a 0–1 position that survives edits.
Four switchable effects
Wave, 3D flip, blur-in, and a derived hue spectrum.
Real 3D depth
Perspective on the title and preserve-3d on characters for the flip.
Reflow-based replay
void offsetWidth lets the same effect re-trigger on repeat clicks.
Inline-block chars
The rule without which every transform silently does nothing.
Composited properties
Effects stay on transform, opacity, and filter.

About this UI Snippet

Splitting.js CSS Stagger — Let CSS Own the Animation, Not JavaScript

Screenshot of the Splitting.js CSS Stagger snippet rendered live

Almost every per-character text animation on the web is driven by a JavaScript timeline: a library splits the text, then the same library animates each piece on a stagger. That works, but it means the animation lives in your bundle, runs on the main thread, and cannot be restyled without editing code.

Splitting.js takes a different position, and it is the reason to choose it over a full animation library. Splitting.js does not animate anything. It splits text into elements and writes CSS custom properties onto them. Every effect after that is plain CSS.

What it actually produces

One call — Splitting() — finds every element carrying data-splitting and rewrites its contents. This:

<h1 data-splitting>Motion</h1>

becomes a nest of <span class="word"> and <span class="char"> elements, each character carrying:

- --char-index — its position in the element, zero-based - --char-total — how many characters there are in total - a data-char attribute holding the character itself

That is the entire API surface being used here, and it is enough to build effects that would otherwise need a timeline library.

Staggering with arithmetic instead of a timeline

The stagger is one CSS declaration:

animation-delay: calc(var(--char-index) * 34ms)

Character 0 starts immediately, character 10 starts 340ms in. There is no loop, no per-element JavaScript, and no timeline object held in memory. The browser schedules all of it, which means the animations run off the main thread wherever the animated properties allow it, and they keep working if your JavaScript later throws.

The effect that shows why --char-total matters

Three of the four effects here only use the index. The fourth uses both, and it is the one that demonstrates the real ceiling of this technique:

color: hsl(calc(190 + var(--char-index) / var(--char-total) * 170), 92%, 68%)

Dividing index by total gives a 0–1 position within the string, which is then mapped across a 170-degree sweep of hue. The result is a gradient distributed evenly across the characters — and because it is derived rather than hard-coded, it stays correct if you rewrite the headline to be twice as long. No JavaScript recalculates anything. This is the trick worth taking away: any per-character value that can be expressed as arithmetic on position can be pure CSS.

The two CSS rules that are not optional

.char { display: inline-block } — inline elements ignore transform entirely. Leave this out and every transform-based effect silently does nothing while opacity effects still work, which is a genuinely confusing way to debug. .word { display: inline-block } does the same job at word level and has the bonus of keeping words from breaking mid-way across lines.

perspective: 800px on the title plus transform-style: preserve-3d on the characters is what makes the flip effect read as rotation in depth rather than a vertical squash. Without a perspective ancestor, rotateX(-92deg) is an orthographic projection — the character simply flattens to nothing.

Replaying a CSS animation

A CSS animation runs once when its class is applied and will not replay just because the class is re-added in the same frame. playEffect() handles this with the standard reflow trick:

EFFECTS.forEach(remove); void title.offsetWidth; title.classList.add(name);

Reading offsetWidth forces a synchronous layout, which makes the browser commit the class removal before the addition. Skip it and clicking the already-active effect does nothing at all, because as far as the style engine is concerned the class never left.

Accessibility

Splitting.js fragments text into dozens of spans, which historically wrecked screen-reader output — assistive tech would read individual letters. Modern screen readers handle inline-block spans inside a heading correctly and announce the full string, but the safe production pattern is to keep the original text in an aria-label on the heading and mark the split output aria-hidden. For anything longer than a headline, that is not optional. A prefers-reduced-motion block that sets animation: none on .char should also ship with this.

Reusing it

Add data-splitting to any element, call Splitting() once after the DOM is ready, and write your effect as a keyframe plus a calc() delay. Splitting also exposes --word-index and --line-index if you would rather stagger by word or by line — the same arithmetic applies. Compare it with GSAP SplitText when you need a real timeline with scrubbing and reversal, or text generate for a simpler word-by-word reveal.

Step by step

How to Use

  1. 1
    Add the Splitting.js CDNInclude splitting from the CDN panel — global Splitting.
  2. 2
    Paste HTML, CSS, and JSThe headline splits into characters and plays the wave effect.
  3. 3
    Switch effectsWave, Flip, Blur, and Spectrum are four CSS keyframes over one split.
  4. 4
    Click the headlineThe active effect replays using the forced-reflow restart.
  5. 5
    Write your own effectAdd a keyframe plus animation-delay: calc(var(--char-index) * Xms).
  6. 6
    Try word-level staggerSwap --char-index for --word-index to animate whole words.

Real-world uses

Common Use Cases

Zero-JavaScript headline entrances
Animate a hero title entirely in CSS, since Splitting only writes --char-index custom properties and the stylesheet does every effect.
Scroll-triggered section reveals
Add the animation class from a reveal on scroll observer, so the characters stagger in as the section appears.
Spectrum brand statements
Colour a tagline across the rainbow, with --char-index divided by --char-total giving a 0 to 1 position that survives any text length.
Splash screen wordmarks
Stagger a wordmark on a splash screen, switching between wave, 3D flip and blur-in effects with a data attribute.
Per-character hover and learning
Build hover states for each letter from the same variables, and use it as a live reference for driving CSS from custom properties.

Got questions?

Frequently Asked Questions

No, and that is the point of choosing it. It only rewrites the element into .word and .char spans and writes --char-index, --char-total and a data-char attribute onto them. Every animation is written by you in CSS, so effects can be restyled or themed without touching JavaScript.

With one declaration: animation-delay: calc(var(--char-index) * 34ms). Character zero starts immediately and each subsequent character is offset by another 34ms. The browser schedules all of it, so there is no per-element JavaScript loop and no timeline held in memory.

It converts an absolute position into a 0-to-1 fraction of the string, which can then be mapped onto any range — here a 170-degree hue sweep. Because it is derived rather than hard-coded, the gradient stays evenly distributed even if you rewrite the headline to a different length.

Inline elements ignore transform completely. Without that rule the wave, flip and blur effects silently do nothing while opacity still animates, which is a confusing failure to debug. The same rule on .word also stops words breaking across lines mid-word.

rotateX without a perspective ancestor is an orthographic projection, so the character just squashes flat instead of rotating in depth. Setting perspective on the title and transform-style: preserve-3d on the characters gives the rotation real depth.

Call Splitting({ target: ref.current }) in a mount effect after the text has rendered, not during render, since it rewrites DOM. Re-run it if the text content changes, and be aware it replaces children — so let Splitting own that subtree rather than having the framework re-render into it. Keep the effect class in state and toggle it; for replay, remove the class, read offsetWidth, then re-add in the same handler.

Design your website