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

@underlying/text

v1.2.1

Published

Accessible text splitting and physics-first reveal for @underlying/core: split into chars, words, lines without breaking screen readers, then reveal, scramble or type it in.

Readme

Split text into chars, words and lines you can animate - without breaking the things naive text-splitting historically breaks. The screen reader still reads the whole text, copy/paste is intact, and emoji stay whole.

npm install @underlying/text @underlying/core

split()

The accessible foundation. A visually-hidden real-text copy stays the only thing screen readers and copy/paste see; the animated pieces are aria-hidden.

import { split } from '@underlying/text'
import { stagger, animate } from '@underlying/core'

const s = split(headline, { type: ['words', 'lines'] })
stagger(s.words, (el) => animate(el, { y: [20, 0], opacity: [0, 1] }), 30)
// s.revert() restores the element, byte-identical
  • Accessible by construction - a screen reader reads "Hello world", not "H-e-l-l-o", and never twice.
  • Emoji-safe - chars use Intl.Segmenter, so flags, ZWJ families and skin-tone sequences stay one piece.
  • Real lines - measured from layout (offsetTop after document.fonts.ready) and re-split on width resize.
  • Lossless revert() and SSR-safe.

Options

  • type (('chars' | 'words' | 'lines')[], default ['words']) - what to expose. Words are always built as the structural unit; 'chars' and 'lines' opt in. The returned chars and lines arrays stay empty unless requested.
  • a11y ('copy' | 'label' | 'off', default 'copy') - how the original text stays readable. This is the headline promise:
    • 'copy' puts a visually-hidden real-text copy in the DOM (preserving nested markup) as the only thing screen readers and copy/paste see, and marks the animated pieces aria-hidden.
    • 'label' sets aria-label on the element instead (no hidden copy, so copy/paste sees the pieces).
    • 'off' does no accessibility handling - you own it.
  • resize (boolean, default true when 'lines' is requested, else false) - re-split lines on a width change. Line membership is a layout fact, so a reflow invalidates it; the observer is width-only and debounced.
  • locale (string) - locale passed to Intl.Segmenter for grapheme segmentation.

reveal()

One call: split, then spring the pieces in - a real spring, overshoot and all, not an eased curve. Reduced-motion safe (it shows immediately, no per-piece motion, under prefers-reduced-motion).

import { reveal } from '@underlying/text'

reveal(headline, { by: 'words', each: 40, from: { y: 24, opacity: 0 } })

Options

  • by ('chars' | 'words' | 'lines', default 'words') - the granularity to stagger in.
  • each (number, default 40) - ms between pieces.
  • from ({ x?, y?, scale?, opacity? }, default { y: 24, opacity: 0 }) - the offset/hidden start state each piece springs from back to rest.
  • duration (number) - per-piece tween duration in ms. Omit it (the default) to spring instead - a real spring, overshoot and all.
  • stiffness, damping (number) - spring tuning, passed through to animate() for each piece.
  • scheduler (Scheduler) - run the reveal on a custom frame clock; the start state is also set on it, so there is no flash.
  • a11y, locale - forwarded to split() (see above).

reveal() returns { split, finished, stop(), revert() }: finished resolves when every piece settles (or on stop()), and revert() stops, releases the per-piece state, and restores the original DOM. Under prefers-reduced-motion the text is shown at rest with no per-piece motion.

scramble() and typewriter()

Content effects on the frame clock (a background tab pauses them). The final text is the accessible name throughout - the changing characters are aria-hidden, never read as gibberish.

import { scramble, typewriter } from '@underlying/text'

scramble(title, 'underlying')      // decode it in
typewriter(line, 'physics-first.') // type it in

Both return { finished, stop() }. finished resolves when the effect completes (or on stop()), and stop() snaps to the final text now. Under prefers-reduced-motion each lands on the final text immediately.

scramble() options

  • duration (number, default 1400) - total time in ms. Positions reveal left to right over this window.
  • chars (string) - the pool of glyphs cycled through for not-yet-decoded positions. Defaults to a built-in symbol/hex pool; whitespace in the target is never scrambled.
  • locale (string) - locale for grapheme segmentation of the target text.
  • scheduler (Scheduler) - frame clock to run on. Defaults to the shared scheduler (so a background tab pauses it).

typewriter() options

  • duration (number) - total time in ms. Defaults to scaling with length, max(400, length * 55), so short strings still take a readable beat and long ones do not crawl. The text is typed one grapheme at a time, so emoji stay whole.
  • locale (string) - locale for grapheme segmentation.
  • scheduler (Scheduler) - frame clock to run on (default shared).

graphemes()

A low-level helper the splitter and effects use internally, also exported for when you need the same grapheme-correct segmentation yourself.

import { graphemes } from '@underlying/text'

graphemes('é')   // ['é'] - 'e' plus a combining acute is ONE cluster, not two
graphemes(text, 'th')  // pass a locale for Intl.Segmenter

It splits a string into grapheme clusters via Intl.Segmenter, so flags, ZWJ sequences (family/profession), combining marks and skin tones stay one piece - unlike [...string], which fixes surrogate pairs but still shatters those. Where Intl.Segmenter is unavailable it falls back to code-point iteration.

License

MIT (c) underlyi.ng