DEV Community

TommyDee
TommyDee

Posted on

Meet @vysmo/text - 243 text animation presets in 3 KB

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.

Text Animations

Three lines

The simplest possible use:

import { animateText } from "@vysmo/text";

animateText(element, { preset: "enter/fade-up" });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 },
//   ],
// }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen 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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then three lines:

import { animateText } from "@vysmo/text";
const h1 = document.querySelector("h1")!;
animateText(h1, { preset: "enter/bloom-scatter" });
Enter fullscreen mode Exit fullscreen mode

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)