Field
The label, hint, error and aria wiring around one input — including the part that is usually wrong, where the hint has to stay in aria-describedby once an error joins it.
A quantity control that is not input[type=number] — so no unstyleable spinner, no accepted "e", and no clamping mid-keystroke that rewrites your 2 to a 10 before you can type the 5.
components/ui/stepper.tsxnpx hoverlab add stepper
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.
One file. Create components/ui/stepper.tsx and paste.
'use client'
/**
* <Stepper> — a number with a minus and a plus.
*
* The one people reach for `<input type="number">` for, and then spend an
* afternoon on. That element brings a locale-dependent spinner you cannot
* style, accepts `e`, `+` and `-` anywhere in the string, reports `""` for
* anything it considers invalid so you cannot tell "empty" from "abc", and
* on a phone shows a keyboard with a decimal point on a field that wants
* integers.
*
* So the field is a text input with `inputMode="numeric"`, and the
* component owns the arithmetic:
*
* - the buttons disable AT the bounds, not after crossing them
* - ArrowUp/ArrowDown step, PageUp/PageDown step by ten, Home/End jump
* to min/max — the same keys the native spinner answers, which is what
* a keyboard user will try
* - typing is unrestricted while the field has focus and clamped on
* blur, because clamping mid-keystroke means typing "25" into a field
* with a minimum of 10 rewrites your "2" to "10"
*
* `role="spinbutton"` with the three aria-value attributes is what makes a
* screen reader announce "3 of 10" instead of reading an unlabelled
* textbox next to two unlabelled buttons.
*/
import * as React from 'react'
import { Minus, Plus } from 'lucide-react'
export interface StepperProps {
value?: number
defaultValue?: number
onChange?: (value: number) => void
min?: number
max?: number
step?: number
/** Appended after the number — "kg", "seats", "×". */
unit?: string
disabled?: boolean
/** Names the control. Required: "3" on its own means nothing. */
label: string
size?: 'sm' | 'md'
className?: string
}
export function Stepper({
value,
defaultValue = 1,
onChange,
min = 0,
max = 99,
step = 1,
unit,
disabled = false,
label,
size = 'md',
className = '',
}: StepperProps) {
const [internal, setInternal] = React.useState(defaultValue)
const current = value ?? internal
const [draft, setDraft] = React.useState<string | null>(null)
const clamp = (n: number) => Math.min(max, Math.max(min, n))
const set = (next: number) => {
const clamped = clamp(next)
if (value === undefined) setInternal(clamped)
onChange?.(clamped)
}
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const jump: Record<string, number> = {
ArrowUp: step,
ArrowDown: -step,
PageUp: step * 10,
PageDown: -step * 10,
}
if (e.key in jump) {
e.preventDefault()
setDraft(null)
set(current + jump[e.key])
} else if (e.key === 'Home') {
e.preventDefault()
setDraft(null)
set(min)
} else if (e.key === 'End') {
e.preventDefault()
setDraft(null)
set(max)
}
}
const height = size === 'sm' ? 'h-8' : 'h-9'
const button =
'inline-flex aspect-square shrink-0 items-center justify-center text-muted-foreground ' +
'transition-colors hover:bg-muted/70 hover:text-foreground focus-visible:outline-none ' +
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ' +
'disabled:pointer-events-none disabled:opacity-40'
return (
<div
role="spinbutton"
aria-label={label}
aria-valuenow={current}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={unit ? `${current} ${unit}` : undefined}
className={[
'inline-flex items-stretch overflow-hidden rounded-lg border border-border bg-background',
'focus-within:ring-2 focus-within:ring-ring',
height,
disabled ? 'opacity-60' : '',
className,
]
.filter(Boolean)
.join(' ')}
>
<button
type="button"
// Labelled, because a bare minus sign is announced as "button".
aria-label={`Decrease ${label}`}
// Hidden from the accessibility tree's value story: the spinbutton
// above already reports the number and its bounds, and a screen
// reader user changes it with the arrow keys.
tabIndex={-1}
disabled={disabled || current <= min}
onClick={() => set(current - step)}
className={`${button} rounded-s-lg border-e border-border`}
>
<Minus className="h-3.5 w-3.5" aria-hidden />
</button>
<input
type="text"
inputMode="numeric"
disabled={disabled}
// While focused the field shows exactly what was typed. Clamping on
// every keystroke turns "2" into "10" on a field whose minimum is
// 10, and the user never gets to type the 5.
value={draft ?? String(current)}
onChange={(e) => setDraft(e.target.value.replace(/[^\d-]/g, ''))}
onBlur={() => {
if (draft !== null) {
const parsed = Number.parseInt(draft, 10)
set(Number.isNaN(parsed) ? current : parsed)
setDraft(null)
}
}}
onKeyDown={onKeyDown}
aria-label={label}
className={[
'w-12 border border-transparent bg-transparent text-center text-sm font-medium tabular-nums',
'text-foreground outline-none disabled:cursor-not-allowed',
].join(' ')}
/>
{unit ? (
<span className="flex select-none items-center pe-2 text-xs text-muted-foreground">
{unit}
</span>
) : null}
<button
type="button"
aria-label={`Increase ${label}`}
tabIndex={-1}
disabled={disabled || current >= max}
onClick={() => set(current + step)}
className={`${button} rounded-e-lg border-s border-border`}
>
<Plus className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
)
}
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
labelrequiredNames the control. Required: "3" on its own means nothing. | string | — |
value | number | — |
defaultValue | number | 1 |
onChange | (value: number) => void | — |
min | number | 0 |
max | number | 99 |
step | number | 1 |
unitAppended after the number — "kg", "seats", "×". | string | — |
disabled | boolean | false |
size | 'sm' | 'md' | 'md' |
className | string | '' |
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
The label, hint, error and aria wiring around one input — including the part that is usually wrong, where the hint has to stay in aria-describedby once an error joins it.
An input with addons, an inline icon or an action button attached, with the padding reserved for the icon and the focus ring on the whole group rather than its middle third.
Six boxes that handle the interaction that actually happens: a pasted code distributes from the first box whichever one was focused, and backspace in an empty box steps back and clears.