ScrollVars / fx
docs · demo · bench · llms.txt · GitHub

Split reveal

A headline broken into words, each rising on its own beat, SplitText without the engine.

Use it for: Hero headlines, section titles, any text that deserves an entrance.
Knobs: data-sv-split (word|char) does the splitting; --sv-stagger (beat), --sv-duration; pair with sv-reading to scrub instead

Words arrive one by one

and the copy follows.

Install: npx scrollvars add split-reveal
Styles: import 'scrollvars/styles/core.css'

<section data-sv data-sv-once class="py-24">
  <h2 class="sv-split-rise text-5xl font-extrabold" data-sv-split>
    Words arrive one by one
  </h2>
</section>
<!-- knobs per element: [--sv-stagger:60ms] [--sv-duration:600ms] -->
<section data-sv data-sv-once>
  <h2 class="sv-split-rise" data-sv-split>Words arrive one by one</h2>
</section>
<!-- data-sv-split wraps each word in a span with --sv-order; the full text
     stays in a visually-hidden span for AT and the animated spans are
     aria-hidden. char mode: data-sv-split="char". -->

/* the preset (styles/core.css): */
.sv, [data-sv] { --sv-live: 0; }
.sv.sv-live { --sv-live: 1; }
/* non-replaced inline boxes ignore translate: the animated spans need it */
.sv-split > span[aria-hidden] { display: inline-block; }
.sv-on .sv .sv-split-rise > span {
  opacity: var(--sv-live); translate: 0 calc((1 - var(--sv-live)) * 0.6em);
  transition: opacity var(--sv-duration, 800ms) var(--sv-ease, cubic-bezier(0.28, 0.84, 0.42, 1)),
              translate var(--sv-duration, 800ms) var(--sv-ease, cubic-bezier(0.28, 0.84, 0.42, 1));
  transition-delay: calc(var(--sv-order, 0) * var(--sv-stagger, 90ms));
}
/* the same sheet's reduced-motion override, and it has to sit AFTER the rule
   it beats: equal specificity, later wins */
@media (prefers-reduced-motion: reduce) {
  .sv-on :is(.sv, [data-sv]) .sv-split-rise > span {
    transition: none; animation: none; opacity: 1; translate: none; }
}
/* the same under html[data-sv-motion="reduce"], the site's own switch */
.sv-on:where([data-sv-motion="reduce"]) :is(.sv, [data-sv]) .sv-split-rise > span { transition: none; animation: none; opacity: 1; translate: none; }

/* a focused split heading must reveal every word at once, not after its
   stagger delay (WCAG 2.4.7): must sit AFTER the reduce blocks above, same
   source-order rule */
.sv-on .sv .sv-split-rise:focus-within > span { transition: none; opacity: 1; translate: none; }

/* scrub instead of play: the same spans feed sv-reading directly */
<h2 class="sv-reading" data-sv-split>…</h2>   <!-- inside a data-sv-pin -->
import { Split } from 'scrollvars/react'

<Reveal>
  <Split as="h2" className="sv-split-rise" stagger={60}>
    Words arrive one by one
  </Split>
</Reveal>

{/* <Split> renders the spans ON THE SERVER. The split markup is in the
   HTML, so no client-side splitting, no layout shift, no hydration
   flash. by="char" for letters. */}

Engine: npm i scrollvars, 12.9 KB gzip as ESM (this page's fx/sv.js IIFE: 13.1 KB). All effects respect prefers-reduced-motion and render complete without JS.