The big text on a landing page deserves to arrive. The headline you spent two hours writing shouldn't just appear — it should choreograph itself into place, letter by letter, so the reader's eye lands on it and stays.
That's what @vysmo/text does. It's a new addition to the vysmo libraries — a tiny, tree-shakable, zero-dependency text animation library with 243 presets and one of the cleanest APIs I've used.
This is a tour.
Three lines
The simplest possible use:
import { animateText } from "@vysmo/text";
animateText(element, { preset: "enter/fade-up" });
That's a real, shippable hero animation. The library splits the text into grapheme-safe slices (more on that below), applies the preset's choreography, and starts playing on mount. No timeline setup, no .from() / .to() chains, no ref management.
If you'd rather have a different feel, change the preset name:
animateText(element, { preset: "enter/elastic-rise" }); // soft spring
animateText(element, { preset: "enter/bloom-scatter" }); // letters arrive from depth
animateText(element, { preset: "enter/whirl-scatter-curl" }); // dramatic, full-on
animateText(element, { preset: "enter/blur-in" }); // tasteful, agency-deck-ready
Same one line. 120 enter presets to choose from. The catalog at vysmo.com/text has all of them with a live playground — click one, type your text, see it.
243 presets, 3 KB
The headline number is real: 229 generated + 14 curated = 243 presets, in roughly 3 KB gzipped. That's a smaller bundle than most loading spinners.
The trick is tree-shaking. Each preset is its own ES module export. You import what you ship:
import { animateText, fadeUp } from "@vysmo/text";
animateText(element, { preset: fadeUp }); // pass the object, not the string
When you pass the preset by reference, only that preset's data lands in your bundle. The other 242 stay tree-shaken out. The string-name form ("enter/fade-up") pulls in a tiny registry — pick whichever ergonomics you prefer.
For a typical landing page using two or three presets, you ship under 2 KB total. For a kinetic-typography portfolio site that uses twenty different presets, you ship maybe 4 KB. Either way, less than a logo SVG.
The presets are split into three categories
Enter (120) — animations for text arriving. fade-up, elastic-rise, bloom-scatter, flip-up-spring, tunnel, plus 115 more. Tuned to feel deliberate, not gimmicky. Most run for ~600–900 ms.
Exit (92) — animations for text leaving. The conceptual mirror of enter, but harder to design well — exits need to feel intentional, not glitchy. fade-down, mist-out, collapse-burst, pinwheel-out. Most react well to being driven faster than their default duration.
Emphasis (31) — animations that loop or play on demand to draw attention. pulse, shake, wobble, coin-flip, spin. These are what you wire to a button click or a "10 items left" badge.
A preset is just data:
import { fadeUp } from "@vysmo/text";
console.log(fadeUp);
// {
// name: "enter/fade-up",
// split: "character",
// stagger: 30,
// animations: [
// { prop: "opacity", from: 0, to: 1, duration: 500, ease: power2.out },
// { prop: "translateY", from: 20, to: 0, duration: 500, ease: power2.out },
// ],
// }
No classes. No inheritance. No registry magic. You can console.log a preset and read it. You can copy one and modify it. You can author your own with the exact same shape.
Two knobs that change everything
Every preset has a default — but the API lets you override any knob per call. Two of them, in particular, are the cheapest way to make the same preset feel completely different.
stagger is the milliseconds between consecutive slices starting. The default is 30 ms (energetic, fast). Bump it to 80 ms and the same preset feels slow and contemplative. Drop it to 10 ms and slices arrive almost together — more of a wash than a choreography.
animateText(element, { preset: "enter/fade-up", stagger: 80 }); // thoughtful
animateText(element, { preset: "enter/fade-up", stagger: 10 }); // urgent
staggerOrder is who gets staggered first. Default is "start" (left-to-right, like reading). But:
animateText(element, { preset: "enter/fade-up", staggerOrder: "end" }); // right-to-left
animateText(element, { preset: "enter/fade-up", staggerOrder: "center" }); // middle outward
animateText(element, { preset: "enter/fade-up", staggerOrder: "edges" }); // both ends inward
animateText(element, { preset: "enter/fade-up", staggerOrder: "random" }); // chaos mode
"center" is the secret weapon. Try it on a hero headline — letters bloom outward from the middle. It feels like the title is being projected rather than typed. Most libraries don't expose this and people don't realize how much they're missing.
Scrolling drives it for free
The handle that animateText returns has a .seek(progress) method that takes a value between 0 and 1 and snaps the animation to that point in its timeline. That's the only thing you need to drive a text animation from scroll position:
const handle = animateText(headline, {
preset: "enter/bloom-scatter",
autoPlay: false,
});
// In a scroll handler — pair with @vysmo/scroll, ScrollTrigger, or your own IntersectionObserver:
function onScroll(progress: number) {
handle.seek(progress);
}
Cinematic word-by-word reveals tied to scroll position. Two lines. The reason this works cleanly is the library's single-master-clock architecture — every slice is animated against the same time origin, so seeking is coherent. That's worth a post on its own (it's coming).
The split is grapheme-safe
One detail that separates this library from most of its predecessors: the text splitting uses Intl.Segmenter. Which means it correctly handles emoji clusters, regional flags, combining marks, and connected scripts.
// Most libraries:
[..."👨👩👧"]; // ['👨','','👩','','👧'] — family emoji shattered into 5 broken pieces
// @vysmo/text:
splitText("👨👩👧", { mode: "character" }).slices.length; // 1 — correct
If your animation has ever broken for users in India, Japan, the UAE, or anywhere with an emoji-rich messaging culture, this is why. The fix has been in browsers since 2020 and most libraries still don't use it. Vysmo does, by default.
Word-mode and line-mode use the same Segmenter, which means they also work correctly in Thai (no spaces between words), Japanese (mixed scripts), and Arabic (right-to-left). If you ship to a global audience by default — which, in 2026, is everyone — this matters.
React, too
There's a thin companion package, @vysmo/text-react, that wraps the runtime in a declarative component plus a useAnimateText hook:
import { AnimateText } from "@vysmo/text-react";
export function Hero() {
return (
<AnimateText as="h1" preset="enter/fade-up">
Hello world
</AnimateText>
);
}
That's the whole component. Props pass through to the underlying runtime — preset, split, stagger, repeat, all of it.
Try it
pnpm add @vysmo/text
Then three lines:
import { animateText } from "@vysmo/text";
const h1 = document.querySelector("h1")!;
animateText(h1, { preset: "enter/bloom-scatter" });
Open vysmo.com/text for the live catalog and the studio that lets you tweak every knob. Source and issue tracker at github.com/vysmodev/vysmo.
Vysmo libraries are MIT, free, and zero-dependency — there's no commercial tier, no rate limit, no telemetry. Just one library doing one thing as cleanly as I could get it.

Top comments (0)