← Back to gallery
CSS

Split Character Stagger

A grapheme-aware split-text reveal — Intl.Segmenter splits user-perceived characters so emoji + non-Latin scripts never get sliced. Three variants: a Latin headline rise, an emoji-safe sequence, and a Hangul / katakana phrase that segments correctly.

intl-segmentergraphemestaggersplit-textaria-labelnon-latin-safeprefers-reduced-motion

Grapheme-aware split · staggered reveal

Split Character Stagger

Grapheme-aware spans stagger into place when Intl.Segmenter is available, while the final readable phrase stays the source of truth. Three variants demonstrate Latin headline, emoji-safe segmentation, and a Hangul phrase whose visual split follows user-perceived characters.

Letter rise · classic cascade

Latin Headline

A concise word reveals one grapheme at a time via vertical rise + scale, the canonical letter-cascade. Visual split is independent from layout measurement; the parent line box stays stable throughout.

  • grapheme
  • rise
  • cascade

Bouncy toss · rotation + scale

Emoji-Safe Label

A row of cute emojis tosses into place with a bouncy scale + slight rotation — the playful motion suits emoji content where each glyph can spin without breaking. The segmentation guarantees every emoji (including multi-codepoint variants) stays one grapheme cluster, never split into UTF-16 surrogate halves.

  • Intl.Segmenter
  • emoji-safe
  • bouncy toss

Horizontal slide · CJK glyphs

Katakana Block

A katakana phrase ("モーション" / "motion") slides in horizontally from the left — the square block geometry of CJK glyphs suits a sideways entrance better than a vertical rise. Demonstrates that the splitter handles non-Latin scripts whose user-perceived characters span more than one Latin width.

  • CJK
  • horizontal slide
  • cluster-safe

Split inspector

Latin Headline

  • grapheme
  • rise
  • cascade

A concise word reveals one grapheme at a time via vertical rise + scale, the canonical letter-cascade. Visual split is independent from layout measurement; the parent line box stays stable throughout.

Helped you ship something? 🐟 Send my cat a churu

.split-phrase {
  color: #f5f3ff;
}

.split-grapheme {
  display: inline-block;
  transform-origin: 50% 100%;
  animation: split-rise 2.60s cubic-bezier(0.22, 1.4, 0.36, 1) infinite both;
  /* Stagger via per-grapheme animation-delay (set as inline style on
     the span — see the JSX tab for the splitter helper). */
  animation-delay: calc(var(--i, 0) * 70ms);
}

@keyframes split-rise {
  0%   { opacity: 0; transform: translateY(14px) scale(0.94); }
  35%  { opacity: 1; transform: translateY(-3px) scale(1.04); }
  55%  { opacity: 1; transform: translateY(0) scale(1); }
  85%  { opacity: 1; transform: translateY(0); }
  100% { opacity: 0; transform: translateY(-4px); }
}

@media (prefers-reduced-motion: reduce) {
  .split-grapheme { animation: none; opacity: 1; transform: none; }
}

How to make this

A split character stagger reveals grapheme spans with per-character animation-delay while the parent keeps the complete phrase as the accessible text.

html
1<p class="split-char-recipe" aria-label="Cascade">2  <span aria-hidden="true" style="--i: 0">C</span>  <span aria-hidden="true" style="--i: 1">a</span>  <span aria-hidden="true" style="--i: 2">s</span>  <span aria-hidden="true" style="--i: 3">c</span>  <span aria-hidden="true" style="--i: 4">a</span>  <span aria-hidden="true" style="--i: 5">d</span>  <span aria-hidden="true" style="--i: 6">e</span></p> <style>.split-char-recipe {  margin: 0;  color: #f5f3ff;  font: 800 clamp(2rem, 8vw, 4rem)/1 ui-sans-serif, system-ui;  letter-spacing: .02em;  text-shadow: 0 0 22px rgba(192, 132, 252, .16);}.split-char-recipe span {20  display: inline-block;  transform-origin: 50% 100%;  animation:    split-char-recipe-rise 2.6s cubic-bezier(.22,1.4,.36,1) infinite both;24  animation-delay: calc(var(--i) * 70ms);}@keyframes split-char-recipe-rise {27  0% { opacity: 0; transform: translateY(.8em) scale(.94); }  35% { opacity: 1; transform: translateY(-.14em) scale(1.04); }  55%, 85% { opacity: 1; transform: translateY(0) scale(1); }  100% { opacity: 0; transform: translateY(-.25em) scale(1); }}@media (prefers-reduced-motion: reduce) {33  .split-char-recipe span {    animation: none;    opacity: 1;    transform: none;  }}</style>

Annotated snippet

  1. Line 1The parent exposes the complete phrase as one accessible string. Screen readers should not announce a heading letter by letter.
    PitfallHow do I make split character animation accessible?

    Expose the complete phrase once with real text or aria-label, then mark visual duplicate spans aria-hidden. Screen readers should receive the message as one phrase, not as a stream of animated letters.

  2. Line 2Each visual character span is aria-hidden because it duplicates the parent label. For dynamic phrases, generate these spans from grapheme clusters, not UTF-16 code units.
    PitfallWhy should split text use Intl.Segmenter?

    JavaScript string indexing splits UTF-16 code units, which can break emoji, accents, and some non-Latin scripts. Intl.Segmenter with granularity: "grapheme" returns user-perceived characters, which is the unit you want to animate.

  3. Line 20Each span is inline-block so transform can move it independently while the surrounding line box remains stable.
    PitfallCan split character stagger cause layout shift?

    It can if the animation changes font size, width, or line height. Keep each span inline-block and animate transform and opacity so the final line footprint stays stable.

  4. Line 24The custom property stores the index. Multiplying it by a fixed delay creates the cascade without hard-coding a separate class per character.

    Both rows raise the word with the same keyframe. Without per-character stagger the four letters of "Wave" move in unison — it reads as a single block bouncing. Staggering each letter by 80ms creates a wave: W rises first, e arrives last, the eye reads the cascade.

    PitfallAre per-character animations expensive?

    A short headline is fine. Long paragraphs create many animated elements and can become expensive, so reserve this pattern for short labels, hero words, or focused callouts.

  5. Line 27The keyframe rises, overshoots once, holds, then fades for the loop reset. The hold matters because readers need time to see the assembled word.

    Same 2.6s loop with per-character stagger. Without a hold the word never sits still — every letter starts fading out before the eye can register the assembled phrase. The held variant (55-85% locks transform at 0) gives readers a 0.8s window where every letter is in place at full opacity.

    PitfallAre per-character animations expensive?

    A short headline is fine. Long paragraphs create many animated elements and can become expensive, so reserve this pattern for short labels, hero words, or focused callouts.

  6. Line 33Reduced motion removes the cascade and leaves the full word visible. The content should not depend on animated entrance timing.
    PitfallWhich browsers support Intl.Segmenter?

    Current Chromium, Firefox, and Safari support Intl.Segmenter. If it is missing, Array.from is a readable fallback for basic text, but complex emoji and script clusters may split incorrectly.

Notes

Overview

Per-character text reveal where each grapheme animates with a staggered animation-delay. The pattern uses Intl.Segmenter (with Array.from fallback) so emoji + non-Latin scripts split on user-perceived character boundaries instead of UTF-16 code units — no broken zero-width joiners, no torn Hangul.

When to use it

Reach for split-char on hero headlines, brand-loud titles, and any string under 25 characters. Skip it for long strings — the cumulative delay becomes a slog. Skip it for any localized string that might contain CJK or bidirectional text without verifying the grapheme split renders correctly.

How it works

At runtime, split the source string with new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(text) — this yields user-perceived characters that respect emoji zero-width joiners, combining marks, and complex scripts. Wrap each segment in a <span> with an inline animation-delay: calc(var(--i) * 35ms) custom property set per index. Each span shares a single @keyframes rule that animates transform: translateY(0.4em) translateZ(0) and opacity from zero to final. The per-span delay creates the cascade. Wrap the parent in an aria-label with the full string so screen readers announce it as a single phrase.

Production gotchas

Wrapping every character in a span hurts the copy-paste experience — the browser inserts whitespace between span children in some configurations. Set white-space: pre on the wrapper and display: inline-block on each span to preserve spacing without leaking padding. Intl.Segmenter is unsupported in older Safari (< 14.1); detect with typeof Intl.Segmenter === 'function' and fall back to Array.from(text) which handles UTF-16 surrogate pairs but not grapheme clusters. Hover/click interactions on per-character spans can accidentally fire on each glyph — set pointer-events: none on the spans and let the parent handle them.

Accessibility

Set aria-label on the parent so screen readers announce the full phrase rather than reading each span as a separate node, and mark the spans with aria-hidden="true". Under prefers-reduced-motion: reduce set all delays and the keyframe duration to zero so all characters appear simultaneously. Verify the final rendered text passes contrast and is not announced with stutter on JAWS / NVDA after the cascade.

References

Implementation depth

The split should be visual only. Preserve the full phrase as an accessible label, then animate grapheme-safe spans so screen readers do not announce each character as a separate item.

Intl.Segmenter matters for names, emoji, and non-Latin scripts. A string split by code unit can break combined glyphs; if segmentation is not available, prefer word-level stagger over unsafe character splitting.