Mikkel Manniche
Dansk English Deutsch

Controls

Stepper

A plus and minus counter with rolling digits that also works with the arrow keys.

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

Preview

Usage

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

Source

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

Install

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/stepper.json

With the registry added (components.json)

npx shadcn@latest add @manniche/stepper

Files

  • stepper.tsx
  • rolling-number.tsx

Manniche Lab · open source

Free tools for AI agents and web development.