Bewegung
Rolling number
Eine Zahl, deren geänderte Ziffern nach oben oder unten rollen. Screenreader hören die ganze Zahl einmal.
Angepasst von Watermelon UI (MIT) und neu geschrieben mit Tastatur, Beschriftungen und reduzierter Bewegung.
Vorschau
Verwendung
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>
)
}Quellcode
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>
)
}Installieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/rolling-number.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/rolling-number