Mikkel Manniche
Dansk English Deutsch

Motion

Rolling number

A number whose changed digits roll up or down, read once by screen readers.

Adapted from Watermelon UI (MIT) and rewritten with keyboard support, labels and reduced motion.

Preview

Usage

rolling-number-demo.tsx
import { useState } from 'react'
import { RollingNumber } from '@/components/rolling-number'

export default function RollingNumberDemo() {
  const [n, setN] = useState(1284)
  return (
    <div className="flex flex-col items-center gap-6">
      <p className="font-serif text-6xl tracking-tight">
        <RollingNumber value={n} locale="da-DK" />
      </p>
      <div className="flex gap-2">
        <button type="button" onClick={() => setN(n - 37)} className="min-h-11 rounded-xl border px-4 text-sm">−37</button>
        <button type="button" onClick={() => setN(n + 137)} className="min-h-11 rounded-xl border px-4 text-sm">+137</button>
      </div>
    </div>
  )
}

Source

rolling-number.tsx
// Based on the rolling digits in Watermelon UI's “Stepper” and “Pagination”
// (MIT, © 2026 Watermelon Platform Contributors, github.com/WatermelonCorp/watermelon-platform).
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useState } from 'react'
import { cn } from '@/lib/utils'

export type RollingNumberProps = {
  value: number
  /** Passed to Intl.NumberFormat, e.g. "da-DK". Leave it out for plain digits. */
  locale?: string
  className?: string
}

/** A number whose changed digits roll up or down. Screen readers get the whole number once. */
export function RollingNumber({ value, locale, className }: RollingNumberProps) {
  // Remember the last value in state (not a ref), so the direction survives a double render.
  const [last, setLast] = useState({ value, dir: 1 })
  const dir = last.value === value ? last.dir : value > last.value ? 1 : -1
  if (last.value !== value) setLast({ value, dir })
  const text = locale ? new Intl.NumberFormat(locale).format(value) : String(value)
  const chars = text.split('')

  return (
    <MotionConfig reducedMotion="user">
      <span className={cn('inline-flex tabular-nums', className)}>
        <span className="sr-only">{text}</span>
        <span aria-hidden className="inline-flex">
          {chars.map((ch, i) => {
            // Count from the right, so the ones stay the ones when the number grows a digit.
            const place = chars.length - i
            return (
              <span key={place} className="relative inline-flex min-w-[1ch] justify-center overflow-hidden">
                <AnimatePresence mode="popLayout" initial={false} custom={dir}>
                  <motion.span
                    key={ch}
                    initial={{ y: `${dir * 70}%`, opacity: 0, filter: 'blur(2px)' }}
                    animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
                    exit={{ y: `${dir * -70}%`, opacity: 0, filter: 'blur(2px)' }}
                    transition={{ type: 'spring', stiffness: 260, damping: 32 }}
                    className="inline-block"
                  >
                    {ch}
                  </motion.span>
                </AnimatePresence>
              </span>
            )
          })}
        </span>
      </span>
    </MotionConfig>
  )
}

Install

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/rolling-number.json

With the registry added (components.json)

npx shadcn@latest add @manniche/rolling-number

Dependencies

Files

  • rolling-number.tsx