@magic-spells/split-text
v0.1.2
Published
Lightweight text-reveal web component — slide-up animation by word, character, or line with no dependencies
Maintainers
Readme
Split Text Web Component
Lightweight text-reveal web component. Splits text into words, characters, or detected lines, then animates each unit with a CSS-driven stagger. Eight built-in effects — rise, drop, slide, bloom, two 3D spins, and a per-character magnetic scatter. No dependencies. No framework. ~2.7 KB gzipped (JS) + 0.9 KB (CSS).
A small alternative to GSAP SplitText / Splitting.js when you don't need the rest of those libraries.
Features
- Three split modes:
words,chars,lines(auto-detected from layout) - Eight effects:
rise(default),drop,slide-right,slide-left,bloom,spin-x,spin-y,magnetic - CSS-only animation —
transform,opacity, andfilter, GPU-accelerated - Triggers when scrolled into view by default (configurable:
load,manual) - Preserves inline markup (
<em>,<strong>,<a>,<br>) inside the host - Emoji-safe character splitting via
Intl.Segmenter - Honors
prefers-reduced-motion - After animation, wrappers swap to
display: contentsso text reflows naturally on resize - Self-registers; safe to import multiple times
Installation
npm install @magic-spells/split-textimport '@magic-spells/split-text';Or include directly:
<script src="https://unpkg.com/@magic-spells/split-text"></script>
<link rel="stylesheet" href="https://unpkg.com/@magic-spells/split-text/css/min" />Usage
<split-text>The quick brown fox jumps over the lazy dog.</split-text>That's it. The element splits on words by default and animates as soon as it scrolls into view.
Modes
<!-- Words (default) -->
<split-text split="words">Each word slides up independently</split-text>
<!-- Characters -->
<split-text split="chars">Letter by letter</split-text>
<!-- Lines (auto-detected from layout) -->
<split-text split="lines">
This longer paragraph wraps onto multiple lines, and each line
animates as a unit. Resize the window before scrolling to it and
the line groups recalculate to match the new layout.
</split-text>Effects
<!-- rise (default) — slides up from below -->
<split-text>Rises into view.</split-text>
<!-- drop — falls in from above -->
<split-text effect="drop">Falls into place.</split-text>
<!-- slide-right / slide-left — sweeps in horizontally -->
<split-text effect="slide-right" split="chars">Sliding in from the right.</split-text>
<split-text effect="slide-left" split="chars">And from the left.</split-text>
<!-- bloom — blur + scale pop, no translate -->
<split-text effect="bloom" split="chars">Comes into focus.</split-text>
<!-- spin-x — rotateX flip (origin: bottom by default) -->
<split-text effect="spin-x" split="chars">Stands up from baseline.</split-text>
<!-- spin-y — rotateY flip (origin: left by default) -->
<split-text effect="spin-y" split="chars">Swings open like a door.</split-text>
<!-- magnetic — per-character random scatter, snaps into place -->
<split-text effect="magnetic" split="chars">Pulled into place.</split-text>The 3D spin effects rely on perspective: 2000px set on the host by default. Override with --split-text-perspective, and shift the pivot with --split-text-origin. The bloom effect's starting state is tunable via --split-text-blur and --split-text-scale.
The magnetic effect is built for split="chars". Each character starts shifted to the right by a random distance at a random height, blurred and transparent, then accelerates home on an ease-in curve (override with easing). The per-unit offsets are randomized in JS — replaying via .split() reshuffles them. Pieces are intentionally not clipped, so they travel outside their box; the starting blur is shared with bloom via --split-text-blur.
Timing
<split-text delay="200" stagger="50" duration="900">
Wait 200ms, then 50ms between each word, 900ms per word.
</split-text>Triggers
<!-- Default: animate when scrolled into view (waits until 100% visible) -->
<split-text trigger="visible">…</split-text>
<!-- Animate immediately on load -->
<split-text trigger="load">…</split-text>
<!-- Manual trigger -->
<split-text id="hero" trigger="manual">…</split-text>
<script>
document.querySelector('#hero').reveal();
</script>Adjusting the trigger position
offset shifts the trigger line up from the bottom of the viewport. The default is "20%" — text animates once the element has scrolled into the bottom fifth of the viewport, rather than the instant it peeks in.
<!-- Default — 20% of viewport height above the bottom -->
<split-text>…</split-text>
<!-- Custom: 200px above the bottom of the viewport -->
<split-text offset="200px">…</split-text>
<!-- Disable the offset — fire on first intersection -->
<split-text offset="0">…</split-text>Custom Easing
<split-text easing="cubic-bezier(0.34, 1.56, 0.64, 1)">
A spring overshoot for that playful feel.
</split-text>Inline Markup
Inline tags inside the host are preserved. Links remain clickable, emphasis still styles, line breaks force new lines:
<split-text split="lines">
Built with <em>care</em> and <a href="/about">a few good ideas</a>.<br>
Try resizing the window before scrolling here.
</split-text>Attributes
| Attribute | Default | Description |
| ------------- | ------------------------------- | ---------------------------------------------------- |
| split | words | words, chars, or lines |
| effect | rise | rise, drop, slide-right, slide-left, bloom, spin-x, spin-y, magnetic |
| delay | 0 | Initial delay before animation starts (ms) |
| stagger | 30 | Delay between each unit (ms) |
| duration | 800 | Animation duration per unit (ms) |
| easing | cubic-bezier(0.16, 1, 0.3, 1) | CSS easing function |
| trigger | visible | visible, load, or manual |
| offset | 20% | Distance above bottom of viewport before firing (px or %; set 0 to disable) |
CSS Custom Properties
Override these for theming. They're already wired through the CSS — set them on the host or any ancestor.
| Property | Default | Description |
| --------------------------- | ------------------------------- | ---------------------------------------------------------------------------- |
| --split-text-duration | 800ms | Animation duration |
| --split-text-easing | cubic-bezier(0.16, 1, 0.3, 1) | Animation easing |
| --split-text-distance | 100% | Travel distance for rise / drop / slide-* (try 40% for subtler) |
| --split-text-stagger | 30ms | Delay between units |
| --split-text-delay | 0ms | Initial delay |
| --split-text-perspective | 2000px | 3D camera distance — applies to spin-x / spin-y |
| --split-text-origin | per-effect | transform-origin for spin (defaults: bottom for spin-x, left for spin-y) |
| --split-text-blur | 2px | Starting blur radius (bloom & magnetic) |
| --split-text-scale | 0.7 | Bloom starting scale |
split-text {
--split-text-easing: ease-out;
--split-text-distance: 60%;
}Methods
const el = document.querySelector('split-text');
el.reveal(); // trigger animation manually (when trigger="manual")
el.split(); // reset and re-split (call after changing innerHTML)Events
Both events bubble.
el.addEventListener('split-text:start', (e) => {
console.log(e.detail); // { split: "words", count: 12 }
});
el.addEventListener('split-text:complete', (e) => {
console.log('done', e.detail);
});How it works
The script walks the host's text nodes, wraps each word (or grapheme, in chars mode) in two nested spans — an outer mask with overflow: hidden and an inner element that animates from a pre-reveal state to its final position. Each inner span gets a --i index; CSS computes animation-delay from it. The effect attribute selects which keyframe runs — duration, easing, and stagger are shared across every effect. (The magnetic effect is the one exception to the overflow: hidden mask: its pieces fly in from outside the box, so the wrapper is set to overflow: visible, and JS stamps each inner span with a random --split-text-mx / --split-text-my start offset.)
For lines mode, the script splits into words first, measures each word's getBoundingClientRect().top after layout, groups consecutive words by their top position (with tolerance for sub-pixel rendering), and assigns every word in a line the same --i. Words are never re-parented, so any inline markup inside (links, emphasis, etc.) survives the split.
Once animation completes, the host gets data-revealed and CSS sets every internal wrapper to display: contents. Layout becomes identical to the original markup — text reflows naturally on resize, with no JS work and no innerHTML thrash.
Accessibility
- The host's
aria-labelis set to the original plain text, so screen readers announce the sentence once instead of wrapper-by-wrapper. - Generated word wrappers are marked
aria-hidden="true"to prevent the AT from re-reading the same text thearia-labelalready covers. - Per the ARIA spec,
aria-hiddenis skipped on wrappers inside interactive ancestors (<a>,<button>,<summary>, form controls,[role="button"], focusable[tabindex], etc.). Nested links and buttons retain their accessible names. prefers-reduced-motion: reducedisables the animation entirely; content shows immediately.
Browser Support
Modern browsers with custom elements + IntersectionObserver support (Chrome, Firefox, Safari, Edge). Intl.Segmenter is used when available for grapheme-correct character splitting; older Safari falls back to spread iterator (handles emoji correctly, just not full grapheme clusters).
License
MIT
