Bedienelemente
Stepper
Ein Zähler mit Plus und Minus und rollenden Ziffern, der auch mit den Pfeiltasten funktioniert.
Angepasst von Watermelon UI (MIT) und neu geschrieben mit Tastatur, Beschriftungen und reduzierter Bewegung.
Vorschau
Verwendung
stepper-demo.tsx
import { Stepper } from '@/components/stepper'
export default function StepperDemo() {
return (
<div className="flex justify-center">
<Stepper label="Quantity" defaultValue={2} min={1} max={20} />
</div>
)
}Quellcode
stepper.tsx
// Based on Watermelon UI's “Stepper” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten with keyboard support and labels.
import { Minus, Plus } from 'lucide-react'
import { useState, type KeyboardEvent } from 'react'
import { RollingNumber } from '@/registry/manniche/rolling-number/rolling-number'
import { cn } from '@/lib/utils'
export type StepperProps = {
/** Controlled value. Leave it out and the stepper keeps its own. */
value?: number
defaultValue?: number
min?: number
max?: number
step?: number
/** Read aloud by screen readers, e.g. "Guests". */
label: string
onChange?: (value: number) => void
className?: string
}
export function Stepper({ value, defaultValue = 0, min = 0, max = 99, step = 1, label, onChange, className }: StepperProps) {
const [own, setOwn] = useState(defaultValue)
const current = value ?? own
const set = (next: number) => {
const v = Math.min(max, Math.max(min, next))
if (v === current) return
setOwn(v)
onChange?.(v)
}
// The number is a spinbutton, so arrow keys, Home and End work as in a native number input.
const onKey = (e: KeyboardEvent) => {
const to: Record<string, number> = {
ArrowUp: current + step,
ArrowRight: current + step,
ArrowDown: current - step,
ArrowLeft: current - step,
Home: min,
End: max,
}
if (e.key in to) {
e.preventDefault()
set(to[e.key])
}
}
const btn =
'grid size-11 shrink-0 place-items-center rounded-xl bg-muted text-foreground transition-[background-color,transform] duration-150 hover:bg-accent active:scale-[0.94] disabled:pointer-events-none disabled:opacity-40'
return (
<div className={cn('inline-flex items-center gap-2 rounded-2xl border bg-card p-1.5 shadow-sm', className)}>
<button type="button" className={btn} onClick={() => set(current - step)} disabled={current <= min} aria-label={`Fewer: ${label}`}>
<Minus className="size-4" aria-hidden />
</button>
<div
role="spinbutton"
tabIndex={0}
aria-label={label}
aria-valuenow={current}
aria-valuemin={min}
aria-valuemax={max}
onKeyDown={onKey}
className="min-w-12 rounded-lg px-1 text-center text-2xl font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<RollingNumber value={current} />
</div>
<button type="button" className={btn} onClick={() => set(current + step)} disabled={current >= max} aria-label={`More: ${label}`}>
<Plus className="size-4" aria-hidden />
</button>
</div>
)
}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/stepper.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/stepper