Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Split text reveal" for my site using the Enrichr Library element at https://enrichr.dev/elements/split-text-reveal.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/split-text-reveal.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- text: Words rise into place
- stagger: 70
- duration: 800
- color: #ffffff
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/split-text-reveal.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<h3 class="ef-split"></h3>CSS
.ef-split { margin: 0; font-size: clamp(2rem, 6vw, 4.2rem); font-weight: 700; line-height: 1.08; letter-spacing: -0.02em; }
.ef-split__w { display: inline-block; overflow: hidden; vertical-align: top; padding-bottom: 0.12em; }
.ef-split__i { display: inline-block; transform: translateY(110%); transition-property: transform; transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); }
.ef-split.is-in .ef-split__i { transform: none; }
@media (prefers-reduced-motion: reduce) { .ef-split__i { transform: none; transition: none; } }JavaScript
// Split text reveal: words slide up from a mask when the heading scrolls into view.
// mount(root, { text, stagger (ms), duration (ms), color }) -> { update, replay, destroy }
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var h = root.querySelector(".ef-split");
var opts = {}, visible = false, io = null;
function render() {
h.textContent = "";
h.setAttribute("aria-label", opts.text);
h.style.color = opts.color;
opts.text.split(/\s+/).forEach(function (word, i) {
var outer = document.createElement("span");
outer.className = "ef-split__w";
outer.setAttribute("aria-hidden", "true");
var inner = document.createElement("span");
inner.className = "ef-split__i";
inner.textContent = word;
inner.style.transitionDuration = opts.duration + "ms";
inner.style.transitionDelay = i * opts.stagger + "ms";
outer.appendChild(inner);
h.appendChild(outer);
h.appendChild(document.createTextNode(" "));
});
h.classList.remove("is-in");
if (reduce || visible) replay();
}
function replay() {
h.classList.remove("is-in");
void h.offsetWidth; // restart the transition
h.classList.add("is-in");
}
function update(next) {
opts = Object.assign({ text: "Words rise into place", stagger: 70, duration: 800, color: "#ffffff" }, opts, next || {});
render();
}
update(o);
if ("IntersectionObserver" in window) {
io = new IntersectionObserver(function (e) { if (e[0].isIntersecting && !visible) { visible = true; replay(); } }, { threshold: 0.2 });
io.observe(root);
} else { visible = true; replay(); }
return { update: update, replay: replay, destroy: function () { if (io) io.disconnect(); } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"text":"Words rise into place","stagger":70,"duration":800,"color":"#ffffff"});How it works
Each word is wrapped in an overflow-hidden mask with an inner span pushed down by 110%. When the heading is 40% visible a single class is added and every inner span transitions to zero, each with its own transition-delay. The delay, not JavaScript timers, makes the stagger, so the browser keeps it smooth.
Accessibility and performance
The heading keeps its full text as an aria-label and the split words are hidden from assistive technology, so it reads as one sentence. With reduced motion the words are in place immediately.
Transform transitions only, run on the compositor. Splitting is one pass over the words when the text changes. No scroll listener: IntersectionObserver fires once.
| Section | Text |
|---|---|
| Techniques | Text animation, Stagger |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Why split by word and not by letter?
Words keep their natural line breaks, so the layout never shifts while it animates. Letter splitting is better for short display text; see the letter pop element.
Does a split heading hurt SEO?
Keep the real text in the heading and put the split spans inside it, as this element does. Search engines read the text content, and the aria-label keeps screen readers on one sentence.
How do I replay it?
Call replay() on the object that mount returns, for example when a tab becomes active.