Skip to content

Recipes

Jace edited this page Aug 30, 2026 · 1 revision

Recipes

Each recipe is complete and runs as written, with nothing to fill in. For the reference, see API. For why the motion behaves as it does, see How the roll works.

A counter that ticks

The common case. It also shows why update() and the value setter are separate calls.

<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>

update() rolls. Assigning el.value sets the text with no motion, which is what you want for the first value and not for the ones after it.

Money, formatted

Intl.NumberFormat produces the grouping separators and the currency symbol, and Scritto keeps whichever glyphs survive. $1,204.00 → $1,205.00 rolls one digit and holds the rest still.

const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })

const setPrice = (cents) => el.update(money.format(cents / 100))

setPrice(120400)
setPrice(120500)   // only the 4 rolls

Feed it the formatted string, never the raw number. The formatter is what puts the separator in, and the separator is what keeps the digits in stable columns.

A value inside a sentence

Wrap the line so the words after the value move with it instead of jumping when the browser reflows.

<scritto-flow>
  You have <scritto-text id="unread">1</scritto-text> unread message.
</scritto-flow>

Give the flow at least 16px of horizontal room if its container hides overflow. The edge fade needs one rem to finish, and a container with 8px of padding and overflow: hidden cuts it off partway. See Flow.

Run something after the roll finishes

There is no "done" event. Read it off the animations, and take both sets: the glyphs live in the shadow tree and the box does not.

const settled = (el) =>
  new Promise((resolve) => {
    el.addEventListener('scrittochange', function done(event) {
      if (event.detail.phase !== 'after') return
      el.removeEventListener('scrittochange', done)
      const anims = [...el.shadowRoot.getAnimations(), ...el.getAnimations()]
      Promise.all(anims.map((a) => a.finished.catch(() => {}))).then(resolve)
    })
  })

const finished = settled(el)     // attach BEFORE the update
el.update('1,000,000')
await finished                   // measured: 702ms for a 550ms roll

Attach the listener before calling update(). update() commits in a microtask, so the animations do not exist yet on the line after the call. Reading them immediately returns an empty list and a promise that resolves in about 1ms. Wait for the after phase, which is when the glyphs exist.

This never resolves if the value did not change, because an unchanged value fires no event at all. Compare against el.value first if that is possible in your code.

Set the first value without animating

An element that rolls on first paint animates from empty, which reads as the page loading twice.

el.value = '1,204'        // no motion
el.update('1,205')        // rolls from here on

Server-rendering does the same job: text inside the tag is the starting value. Import @scritto/core/ssr.css if it has to look right before the script runs.

React, from state

import Scritto from '@scritto/react'

function Cart({ total }) {
  return <Scritto value={total} trend={1} />
}

The wrapper does not animate on mount, and animates on every change after it. trend={1} makes the glyphs arrive from below. Leave it off and the direction is read from the numbers.

Pin the direction when the numbers lie

trend: 0, the default, compares each number in the old value with its counterpart in the new one and lets the first differing pair decide. That is right for a counter and wrong for something like a shuffled label, where consecutive values have no order.

el.setOptions({ trend: 1 })    // always arrive from below

Values that gain or lose a number entirely read as a rise, because there is nothing to compare position for position.

Slow it down, or make it bounce

el.setOptions({ transition: { duration: 900 }, bounce: true })

duration is one glyph's animation, not the whole change: the last glyph starts late, so a 400ms duration finishes somewhere past 460ms. Timing has the arithmetic.

Keep the duration shorter than the gap between your updates. A value that changes faster than its own roll stacks ghosts, the copies of glyphs that are leaving, on screen. One is legible; four are not.

Reduced motion

Nothing to do. respectMotionPreference is on by default, so a reader with prefers-reduced-motion: reduce gets the new value with no animation. An element scrolled off screen also renders instantly rather than animating out of view.

el.setOptions({ respectMotionPreference: false })   // opt out of the preference

Clone this wiki locally