npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

split-reveal

v0.4.0

Published

Scroll-linked per-character text reveal in CSS. The split runs at build time, so nothing ships to the browser but markup and a stylesheet.

Readme

split-reveal

Scroll-linked per-character text reveal, in CSS. The split runs at build time, so nothing ships to the browser but markup and a stylesheet.

Live demo →

715 B  gzipped CSS
  0    runtime dependencies
  0    bytes of JavaScript on the page

Characters move with the wheel, in both directions, for as long as the element is on screen. Scroll is the timeline, not a trigger that fires a clip.


Why this exists

The usual way to do this is GSAP with ScrollTrigger and SplitText: about 51 KB gzipped, splitting the DOM at runtime, re-measuring on resize. This does the same effect with a stylesheet and a build step.

The trade is real and worth stating plainly: you give up Firefox (see Browser support), and you give up per-line masking, because line breaks only exist after layout and this never measures layout.

Install

npm i split-reveal

Use

Import the stylesheet once, anywhere in your app:

import 'split-reveal/css'

Astro

---
import SplitReveal from 'split-reveal/astro'
---
<SplitReveal as="h1" class="hero-title" text="Scroll is the timeline." />
<SplitReveal as="p" mode="fade" text="Not a trigger that fires a clip." />

Any other build step

splitText() is a plain function with no framework attached. Call it wherever you render.

import { splitText } from 'split-reveal'

const title = splitText('Scroll is the timeline.')

// String templates (11ty, Hono, plain Node):
title.toElement('h1', { class: 'hero-title' })

// Frameworks that build real nodes read the tree instead:
title.tokens      // [{ type: 'word', chars: [{ value: 'S', index: 0 }, …] }, …]
title.attributes  // { 'data-split-reveal': 'rise', style: '--split-start:8%;…' }

In React:

const t = splitText('Scroll is the timeline.')

<h1 className="hero-title" {...t.attributes}>
  <span className="split-a11y">{t.text}</span>
  <span aria-hidden="true">
    {t.tokens.map((token, i) =>
      token.type === 'space' ? ' ' : (
        <span className="split-word" key={i}>
          {token.chars.map((c) => (
            <span className="split-char" key={c.index} style={{ '--split-i': c.index }}>
              {c.value}
            </span>
          ))}
        </span>
      ),
    )}
  </span>
</h1>

Without a build step

A hand-written HTML page has nowhere to run the split, so run it in the browser instead. The animation is CSS either way. The module builds the markup once and then does nothing for the rest of the page's life.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/split-reveal@0/dist/split-reveal.css">

<h1 data-split>Scroll is the timeline.</h1>
<p data-split="fade">Not a trigger that fires a clip.</p>

<script type="module">
  import { splitText } from 'https://cdn.jsdelivr.net/npm/split-reveal@0/+esm'

  for (const el of document.querySelectorAll('[data-split]:not([data-split-reveal])')) {
    const result = splitText(el.textContent.trim(), { mode: el.dataset.split || undefined })
    el.setAttribute('data-split-reveal', result.attributes['data-split-reveal'])
    el.style.cssText += result.attributes.style
    el.innerHTML = result.toHTML()
  }
</script>

Two details there are load-bearing. || undefined rather than || 'rise', because an empty data-split yields an empty string and splitText skips undefined options so the default applies. And :not([data-split-reveal]), because after the split el.textContent holds the sentence twice, once in the hidden copy for assistive technology and once in the split copy, so a second pass would split the doubled string.

The cost is the property the top of this README claims: 1.3 kB gzipped over the CDN, and a moment where the plain text is on screen before it is replaced. Where the copy is static, generate the markup once instead and paste it into the page:

npx -y -p split-reveal node -e "import('split-reveal').then(m => console.log(m.splitText('Scroll is the timeline.').toElement('h1', { class: 'hero' })))"

That leaves nothing on the page but markup and the stylesheet.

Modes

| Mode | What it does | Use for | |---|---|---| | rise (default) | Masks the word with clip-path and lifts characters into it | Headlines | | fade | Steps characters in with steps(1). No mask, stays fully inline | Body copy, ledes | | nudge | Hides with visibility, no mask, so the travel distance is yours to set | Short travel at any size |

fade is stepped rather than faded on purpose. A real fade shows a cloud of half-transparent letters for the whole scroll range; a single step means a character is either set or absent, which stays legible while the page is moving.

nudge exists because in rise the travel distance is not a setting. The character hides behind the word's mask, so it has to clear that mask's lower edge or a sliver of it stands in the line for the whole trip. That puts a floor under the distance at the glyph's own height, around 1.36em for a text face, and no combination of line-height and bleed gets below it: at 2.5rem that is a 67px trip per letter. nudge hides with visibility instead. The concealment no longer depends on where the character is, so the distance comes loose and --split-travel sets it, 0.25em by default. The mask goes with it; what stays is the box per character, and with it the lost hyphenation.

API

splitText(text, options?)

| Option | Default | Meaning | |---|---|---| | mode | 'rise' | 'rise', 'fade' or 'nudge' | | start | 8 | Range start of the first character, in percent of cover | | end | 34 | Range end of the first character, in percent of cover | | spread | 22 | Scroll distance between the first and the last character finishing, in percentage points of cover | | step | null | Scroll distance from one character to the next, written rather than derived. Replaces spread; passing both throws |

The last character therefore lands at end + spread percent of cover, 56% by default, or at end + step * (count - 1) where you set a step. A block needs that much scroll left below it, which matters at the bottom of a page: see A block needs scroll room below it.

Returns a SplitResult:

| Member | Type | | |---|---|---| | text | string | The untouched input | | count | number | Number of characters | | tokens | SplitToken[] | Word and whitespace tokens, each word carrying its characters and their running index | | attributes | SplitAttributes | data-split-reveal and the style string for the wrapping element | | toHTML() | string | Inner markup: hidden original plus the aria-hidden split copy | | toElement(tag?, attrs?) | string | The complete element |

TypeScript

Declarations ship with the package, so there is nothing to install and no @types/ counterpart. They are generated from the JSDoc in src/, which keeps them from drifting away from the implementation.

tokens is a discriminated union. Narrowing on type is what hands you chars, because whitespace tokens do not carry any:

import { splitText } from 'split-reveal'
import type { SplitToken } from 'split-reveal'

function longestWord(tokens: SplitToken[]): number {
  return tokens.reduce((n, t) => (t.type === 'word' ? Math.max(n, t.chars.length) : n), 0)
}

longestWord(splitText('Scroll is the timeline.').tokens)

Exported types: SplitMode, SplitOptions, SplitChar, SplitWordToken, SplitSpaceToken, SplitToken, SplitAttributes and ElementAttributes, plus the SplitResult class. The Astro component's props extend SplitOptions, so mode, start, end, spread and step are typed there too.

Why spread and not a delay

A scroll timeline has no time. Forty animation-delay values would all resolve to the same frame, because nothing is playing at a rate that a delay could offset. The stagger has to be a shift of animation-range instead:

animation-range:
  cover calc(var(--split-start) + var(--split-i) * var(--split-step))
  cover calc(var(--split-end)   + var(--split-i) * var(--split-step));

--split-step is derived from spread and the character count, not written by hand. That is the part worth taking: spread is held constant, so a six-word headline and a forty-word paragraph both finish over the same share of their own scroll. Hand-tuned per-block values drift the moment the copy changes.

Reading order also holds at any scroll speed, which a fixed delay cannot promise.

spread or step

The two describe the same stagger from opposite ends, and which one you want is a design decision rather than a default:

| | spread | step | |---|---|---| | Held constant | the length of the whole run | the distance between two characters | | Grows with the copy | the density of the stagger | the length of the whole run | | Characters in flight at once | all of them, past ~60 characters | always (end - start) / step |

spread is the default because it makes a page of blocks feel of a piece: every one of them takes the same share of its own scroll, whatever the copy says. It has one limit. The step it derives is spread / (count - 1), so the longer the text the finer the step, and past roughly sixty characters every character in the block is moving at once. The wave stops reading as a wave and the paragraph blurs as a whole.

step is the fix for that case. Writing { start: 8, end: 18, step: 0.25 } keeps 40 characters in flight whether the block is a line or a paragraph, which is the narrow travelling front you see on sites that stagger with a tweening library. What you give up is the shared timing: a long block now takes far more scroll than a short one.

Reach for step on long copy, and check end + step * (count - 1) against the scroll you have below the block.

CSS custom properties

Set these on the element, or anywhere above it.

| Property | Default | | |---|---|---| | --split-bleed-top | 0.4em | Headroom the mask leaves above the line box, for ascenders and umlaut dots | | --split-bleed-bottom | 0.25em | Headroom below, for descenders | | --split-ease | linear | How one character travels its own range | | --split-travel | 0.25em | How far a nudge character travels. Negative brings it down from above | | --split-fallback-duration | 500ms | Fallback only | | --split-fallback-stagger | 14ms | Fallback only | | --split-fallback-ease | cubic-bezier(.16,1,.3,1) | Fallback only |

Raise both bleeds for display faces set below line-height: 1, where glyphs sit well outside the line box. Too much bleed costs nothing; too little shaves the tops off umlauts.

--split-ease shapes one character's own travel, never the stagger: that stays a shift of the range and stays even. Linear is the default because it is the honest mapping of scroll to motion and the only curve that looks the same scrolled backwards. An ease-out reads softer and is what a tweening library applies without being asked, cubic-bezier(.33,.67,.67,1) being exactly the quadratic one. fade ignores the property, since a single step has nothing to interpolate.

All rules live in an @layer split-reveal cascade layer, so your own unlayered CSS wins without needing !important.

Tailwind CSS v4

Declare the layer order before the imports, or the library will outrank every utility:

@layer theme, base, components, split-reveal, utilities;

@import "tailwindcss";
@import "split-reveal/css";

A cascade layer that is registered late wins, and an @import after tailwindcss registers split-reveal after utilities. With the order declared up front, split-reveal sits between components and utilities, which is where it belongs: your utilities still win, and you never reach for !important. The @layer statement has to come before any @import to take effect.

Accessibility

  • The split copy is aria-hidden. The untouched string sits beside it in a visually hidden span, so assistive technology reads sentences, never letters.
  • Under prefers-reduced-motion: reduce nothing animates at all: the copy renders as plain text.
  • Only transform and visibility are animated. transform is compositor-only; visibility is discrete, so a character costs one paint across the whole scroll rather than a value interpolated every frame. No will-change.

Browser support

| | animation-timeline: view() | |---|---| | Chrome / Edge | 115+ | | Chrome Android | 115+ | | Safari / iOS | 26+ | | Firefox | behind a flag | | Firefox Android | not supported |

Without support the copy renders as plain, unanimated text. That is the intended resting state, not a broken one, and it needs no JavaScript.

If you would rather have something animated there, opt into the fallback:

import 'split-reveal/fallback'

It is roughly 600 bytes and trades the scroll coupling for a fire-once IntersectionObserver transition: the reveal still reads left to right, but it plays on its own clock and does not run backwards. Different effect, similar look. It stays out of the bundle unless you ask for it.

Known limitations

Hyphenation is off for rise and nudge

rise needs a box per character, because transform does not apply to non-replaced inline elements. Each of those boxes is also a line-break opportunity, which would let a long compound break mid-word with no hyphen, so the word wrapper carries white-space: nowrap.

Hyphenation is therefore off for rise, and for nudge, which needs the same box. A long German compound at hero size can run out of the line on narrow viewports. Check headline copy at 375px, or use fade, which stays fully inline and breaks exactly like untouched text.

A block needs scroll room below it

animation-timeline: view() sits on the characters, so every character is its own timeline subject. cover starts when that character's top edge touches the bottom of the viewport and ends when its bottom edge leaves the top, which makes the range viewport height + line height long. The height of the block it sits in never enters into it. The last character lands at end + spread percent of that range, 56% with the defaults.

At the bottom of a page the scroll runs out before that. Once the end of the document sits on the bottom of the viewport nothing moves any further, so the last character of a block, with B of content below it, on a line h tall, in a viewport V tall, only ever reaches:

(B + h) / (V + h)

of its cover range. For a block that is the last thing on the page that is a few percent, and the closing characters never arrive. Nothing errors; the text just sits there half revealed.

Keep roughly end + spread percent of the viewport height below the block, so about 56vh with the defaults. Because h is small next to V, that figure holds whatever the block is: a one-line headline needs the same room as a ten-line paragraph. An ordinary footer covers it. A headline as the last element on the page does not. Where the layout cannot give that room, lower end and spread for that block instead.

How it compares

| | Splits | Animates | Runtime cost | |---|---|---|---| | split-reveal | build time | yes, scroll-linked in CSS | none | | Splitting.js | runtime | no, gives you the custom properties | ~2 KB | | SplitType | runtime | no | ~3 KB | | GSAP SplitText + ScrollTrigger | runtime | yes, with a full timeline API | ~51 KB |

If you need per-line masking, timeline control or Firefox today, use GSAP. If you want the effect and nothing else, use this.

Licence

MIT © Robin Gogolok