Mikkel Manniche
Dansk English Deutsch

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.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/stepper

Dateien

  • stepper.tsx
  • rolling-number.tsx