Skip to content

Navigation Menu

Sign in
Sign up

Repository files navigation

Scritto

SwiftUI's numeric text transition, on the web.

License: MIT TypeScript

scrit.to

npm install @scritto/core
<scritto-text id="count">0</scritto-text>
<script type="module">
 import "@scritto/core";
 const el = document.querySelector("#count");
 let n = 0;
 setInterval(() => el.update(String(++n)), 1000);
</script>

That is the whole thing. update() rolls; assigning .value sets the text with no motion, which is what you want for the first value.

What it is

Most web number animations only handle digits. SwiftUI's .numericText morphs any string, a status or a price or a label, and keeps the letters that stay put. Scritto does that on the web: it diffs the old value against the new one, holds the glyphs that survive, slides them to where they now belong, and rolls the rest out while their replacements roll in. Wrap the line in <scritto-flow> and the words beside the value move on the same clock instead of jumping when the browser reflows.

Every glyph renders in its own span, which is the price of the whole effect. Nothing can slide independently unless it is its own box, so ligatures and kerning do not apply to a value while Scritto owns it, and a value breaks between its words but never inside one. If you need a paragraph of text to reflow, this is the wrong tool; if you need a number, a price, a status or a short label to change without the eye losing it, this is exactly the tool.

Quick start

Vanilla

<scritto-text></scritto-text>
<script type="module">
 import "@scritto/core";
 const text = document.querySelector("scritto-text");
 text.update("1,000"); // rolls. Assign .value instead to set it without motion
</script>

Wrap nearby copy in <scritto-flow> so words slide and rewrap with the value:

<scritto-flow>
 Used <scritto-text></scritto-text> billion tokens this month.
</scritto-flow>

React

npm install @scritto/react
import Scritto from "@scritto/react";
<Scritto value="1,000" />

Vue

npm install @scritto/vue
<script setup>
import Scritto from "@scritto/vue";
</script>
<template>
 <Scritto value="1,000" />
</template>

Svelte

npm install @scritto/svelte
<script>
 import Scritto from "@scritto/svelte";
</script>
<Scritto value="1,000" />

Solid

npm install @scritto/solid
import Scritto from "@scritto/solid";
<Scritto value="1,000" />

Every framework package re-exports the core types and depends on @scritto/core, so you do not install it separately.

Packages

Sizes are gzipped and measured by bun run size against the built output, not estimated.

Package npm Size Description
@scritto/core npm 11.47 KB Framework-agnostic web component
@scritto/react npm 0.58 KB React component
@scritto/vue npm 0.68 KB Vue component
@scritto/svelte npm 0.50 KB Svelte component
@scritto/solid npm 0.65 KB Solid component

A wrapper is a thin binding over the element, so the core is what you are actually paying for. Zero runtime dependencies.

What it does

  • Numbers and arbitrary strings, not just digits
  • Keeps a shared prefix, a shared suffix, and a run flush with neither end, the last of which SwiftUI does not do
  • <scritto-flow> slides and rewraps the words beside a value when it changes width
  • Per-glyph enter and exit with stagger, blur, scale and trend; bounce adds overshoot
  • Ghosts fade at an edge only where they would hit a neighbour or leave their container, so text with room around it keeps its shape (edgeFade overrides it)
  • Graphemes rather than code points, so emoji, ZWJ sequences, combining marks, CJK and RTL survive intact
  • SSR-safe, prefers-reduced-motion honoured by default, and the value kept readable by assistive tech: the element carries it as a plain text node in the light DOM with every animated glyph aria-hidden, and every framework wrapper also sets role="img" with an aria-label

It needs Intl.Segmenter, the Web Animations API, CSS masks and linear() easing, and polyfills none of them.

What it does not do

Ligatures and kerning are off inside a value. Every glyph is its own span, which is what lets one slide while its neighbour stays. There is no version of this that keeps both.

A value breaks between its words and nowhere else. Spaces inside a value become non-breaking and each word is its own inline-block, so a line can only break where one word ends and the next begins. A value with no spaces in it, a long formatted number for instance, has nowhere to break and runs past a narrow container rather than wrapping, so you size the container rather than the value.

Updating faster than the roll stacks ghosts. An outgoing glyph is never cancelled by the next update, because a digit that pops out of existence halfway through reads worse than one that finishes leaving. Change a value several times inside one roll duration and you get several outgoing copies over each other. The fix is a duration shorter than the gap between your updates, not a shortcut that speeds the pile away.

Interrupting a <scritto-flow> word mid-wrap looks wrong for a few frames. A word changing line is drawn by two ghosts while its real box sits hidden at the destination, measured 30–34px from where the reader sees it. Interrupt that and the slide restarts from a place nothing was drawn. It settles correctly and leaves nothing behind; it is a known rough edge, not a leak.

An embedded flow needs 16px of gutter. The edge fade needs one rem to finish. A container that gives it less and hides its overflow cuts the fade off partway and leaves exactly the hard edge the mask exists to avoid.

Contributing

Issues and PRs welcome.

License

MIT

Docs

The wiki has the rest. Start with recipes for working code by task, or the glossary if a page uses a word you do not recognise. Then: how the roll works, what SwiftUI does and the two places we differ, sixty-five measured cases, timing, flow, edges, the API and testing.

Based on numeric-text by shizukushq (MIT).


Built by Jace

X | Bluesky | Instagram | Threads

About

Resources

Stars

64 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages

AltStyle によって変換されたページ (->オリジナル) /