Rolling Counter
A number stepper whose digits roll like an odometer, with springy plus and minus buttons.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/rolling-counter.json'use client'
import { useState } from 'react'
import './rolling-counter.css'
export interface RollingCounterProps {
value?: number
defaultValue?: number
onValueChange?: (value: number) => void
min?: number
max?: number
step?: number
label?: string
/** Font size of the number in px. */
size?: number
variant?: 'pill' | 'plain'
fill?: 'dark' | 'light'
className?: string
}
/** Digits that roll like an odometer. Each column only moves as far as its own digit changes. */
export function RollDigits({ text }: { text: string }) {
const chars = text.split('')
return (
<span className="sd-roll" aria-hidden>
{chars.map((ch, i) => {
const k = chars.length - i
return /\d/.test(ch) ? (
<span key={k} className="sd-roll__col">
<span className="sd-roll__strip" style={{ transform: `translateY(${-Number(ch) * 10}%)` }}>
{'0123456789'.split('').map((d) => (
<span key={d}>{d}</span>
))}
</span>
</span>
) : (
<span key={`s${k}`}>{ch}</span>
)
})}
</span>
)
}
/** A stepper whose number rolls between values. role="spinbutton" with arrow, Page and Home/End keys. */
export function RollingCounter({ value, defaultValue = 0, onValueChange, min = 0, max = 999, step = 1, label = 'Quantity', size = 56, variant = 'pill', fill = 'dark', className }: RollingCounterProps) {
const [inner, setInner] = useState(defaultValue)
const v = value ?? inner
const set = (n: number) => {
const c = Math.min(max, Math.max(min, n))
if (c === v) return
if (value === undefined) setInner(c)
onValueChange?.(c)
}
return (
<div className={['sd-rc', className].filter(Boolean).join(' ')} data-fill={fill} data-variant={variant} style={{ '--rc-size': `${size}px` } as React.CSSProperties}>
<button type="button" className="sd-rc__btn" aria-label={`Decrease ${label}`} disabled={v <= min} onClick={() => set(v - step)}>
<svg viewBox="0 0 16 16" aria-hidden><path d="M3.5 8h9" /></svg>
</button>
<span
className="sd-rc__num"
role="spinbutton"
tabIndex={0}
aria-label={label}
aria-valuenow={v}
aria-valuemin={min}
aria-valuemax={max}
onKeyDown={(e) => {
const map: Record<string, number> = { ArrowUp: v + step, ArrowRight: v + step, ArrowDown: v - step, ArrowLeft: v - step, PageUp: v + step * 10, PageDown: v - step * 10, Home: min, End: max }
if (e.key in map) {
e.preventDefault()
set(map[e.key])
}
}}
>
<RollDigits text={String(v)} />
</span>
<button type="button" className="sd-rc__btn" aria-label={`Increase ${label}`} disabled={v >= max} onClick={() => set(v + step)}>
<svg viewBox="0 0 16 16" aria-hidden><path d="M3.5 8h9M8 3.5v9" /></svg>
</button>
</div>
)
}.sd-rc { --bg: #232323; --fg: #fafafa; --btn: #333; --line: rgb(255 255 255 / 0.08);
display: inline-flex; align-items: center; gap: 10px; padding: 8px; border-radius: 999px; color: var(--fg); font-feature-settings: 'tnum'; }
.sd-rc[data-fill='light'] { --bg: #fff; --fg: #111; --btn: #efefef; --line: rgb(0 0 0 / 0.08); }
.sd-rc[data-variant='pill'] { background: var(--bg); box-shadow: inset 0 0 0 1px var(--line), 0 10px 30px -12px rgb(0 0 0 / 0.5); }
.sd-rc__btn { display: grid; place-items: center; width: calc(var(--rc-size) * 0.8); height: calc(var(--rc-size) * 0.8); border-radius: 999px; background: var(--btn); color: var(--fg); transition: transform 220ms cubic-bezier(0.3, 1.6, 0.4, 1), opacity 200ms; }
.sd-rc__btn svg { width: 40%; height: 40%; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
.sd-rc__btn:active:not(:disabled) { transform: scale(0.86); }
.sd-rc__btn:disabled { opacity: 0.35; }
.sd-rc__num { min-width: 2ch; padding: 0 4px; text-align: center; font-size: var(--rc-size); font-weight: 600; letter-spacing: -0.04em; border-radius: 12px; }
.sd-rc :focus-visible { outline: 2px solid var(--fg); outline-offset: 3px; }
.sd-roll { display: inline-flex; line-height: 1.1; }
.sd-roll__col { display: inline-block; height: 1.1em; overflow: hidden; }
.sd-roll__strip { display: flex; flex-direction: column; transition: transform 650ms cubic-bezier(0.2, 1.35, 0.35, 1); }
.sd-roll__strip > span { height: 1.1em; }
@media (prefers-reduced-motion: reduce) { .sd-rc *, .sd-roll__strip { transition: none !important; } }Overview
Each digit is its own column that rolls only as far as it needs to, so 99 → 100 rolls the tens and hundreds while the ones flip over.
Under it is a real spinbutton: arrow keys step, Page keys jump by ten and Home/End go to the limits.
When to use
- Quantities, seats, guests and other small integers.
- Live counters (followers, votes) where a rolling change feels rewarding.
When not to use
- Free-form or decimal numbers: use a text input with inputMode="decimal".
- Ranges over a few hundred where typing is faster.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/rolling-counter.json$
npx shadcn@latest add https://sundaeui.dev/r/rolling-counter.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/rolling-counter.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/rolling-counter.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy rolling-counter.tsx and rolling-counter.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { RollingCounter } from '@/components/sundae/rolling-counter'
const [seats, setSeats] = useState(2)
<RollingCounter value={seats} onValueChange={setSeats} min={1} max={20} label="Seats" />Props
| Prop | Type | Default |
|---|---|---|
valueControlled value. | number | — |
defaultValueInitial value when uncontrolled. | number | 0 |
onValueChangeCalled with the clamped value. | (v: number) => void | — |
min / max / stepRange and step. | number | 0 / 999 / 1 |
labelAccessible name. | string | 'Quantity' |
sizeFont size of the number, px. | number | 56 |
variantPill container or bare. | 'pill' | 'plain' | 'pill' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The number is role="spinbutton" with aria-valuenow/min/max.
- Buttons have explicit labels ("Increase Seats") and disable at the limits.
- Reduced motion turns the roll into an instant change.
Customization
- Every colour is a CSS custom property on the root class; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ↑ / →
- Increase by step
- ↓ / ←
- Decrease by step
- Page Up / Down
- Change by ten steps
- Home / End
- Jump to min / max
FAQ
- Is Rolling Counter free?
- Yes. Rolling Counter is free for personal and commercial projects, with full source on this page.
- How do I install Rolling Counter?
- Run npx shadcn@latest add https://sundaeui.dev/r/rolling-counter.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Rolling Counter support keyboard and screen readers?
- The number is role="spinbutton" with aria-valuenow/min/max. Buttons have explicit labels ("Increase Seats") and disable at the limits.
- Does Rolling Counter respect reduced motion?
- Reduced motion turns the roll into an instant change.
More like this
All forms →Jelly Switch
Forms · Playful- Pro
Onboarding Flow
Forms · Refined Unit Input
Forms · RefinedOTP Field
Forms · RefinedDate Picker
Forms · UnderstatedCombobox
Forms · Understated