Mikkel Manniche
Dansk English Deutsch

Bevægelse

Rolling number

Et tal, hvor de cifre, der ændrer sig, ruller op eller ned. Skærmlæsere hører hele tallet én gang.

Tilpasset fra Watermelon UI (MIT) og skrevet om med tastatur, etiketter og reduceret bevægelse.

Forhåndsvisning

Brug

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

Kilde

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

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

Med registret tilføjet (components.json)

npx shadcn@latest add @manniche/rolling-number

Afhængigheder

Filer

  • rolling-number.tsx