Mikkel Manniche
Dansk English Deutsch

Kontroller

Stepper

En tæller med plus og minus og rullende cifre, som også virker med piletasterne.

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

Forhåndsvisning

Brug

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

Kilde

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

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

Med registret tilføjet (components.json)

npx shadcn@latest add @manniche/stepper

Filer

  • stepper.tsx
  • rolling-number.tsx