Number Stepper
The number slides up when you add and down when you subtract, so the direction of the change is felt as well as read.
Create a quantity stepper in React and CSS with minus and plus buttons around a number. Keep the value and the direction of the last change in state. Render the number in a span keyed by the value so React remounts it on every change, and set data-dir="up" or "down". On mount, play a 280ms keyframe with cubic-bezier(0.16, 1, 0.3, 1): from translateY(60%) and opacity 0 for up, from translateY(-60%) for down, to translateY(0) and opacity 1. The number box has overflow: hidden and tabular-nums. Clamp the value between 1 and 9 and disable the button at each limit. Wrap the number in aria-live="polite" so the new value is announced. Under prefers-reduced-motion, skip the slide. The label reads "Guests".
Source
'use client'
import { useState } from 'react'
import s from './demo.module.css'
export default function NumberStepper({ labels }: { labels: Record<string, string> }) {
const [state, setState] = useState({ n: 2, dir: 'up' })
const step = (d: 1 | -1) => setState(({ n }) => ({ n: n + d, dir: d > 0 ? 'up' : 'down' }))
return (
<div className={s.wrap}>
<span className={s.label}>{labels.label}</span>
<div className={s.stepper}>
<button type="button" className={s.btn} aria-label={labels.minus} disabled={state.n <= 1} onClick={() => step(-1)}>
−
</button>
<span className={s.value} aria-live="polite">
<span key={state.n} className={s.num} data-dir={state.dir}>
{state.n}
</span>
</span>
<button type="button" className={s.btn} aria-label={labels.plus} disabled={state.n >= 9} onClick={() => step(1)}>
+
</button>
</div>
</div>
)
}
.wrap {
display: grid;
justify-items: center;
gap: 12px;
}
.label {
font: 500 var(--text-sm) / 1 var(--font-body);
color: var(--stage-muted);
}
.stepper {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid var(--stage-rule);
border-radius: 999px;
background: var(--stage-surface);
}
.btn {
width: 44px;
height: 44px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--stage-ink);
font: 400 var(--text-2xl) / 1 var(--font-body);
cursor: pointer;
transition: background-color var(--dur-micro);
}
.btn:hover:not(:disabled) { background: var(--stage-bg); }
.btn:disabled { color: var(--stage-rule); cursor: not-allowed; }
.btn:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.value {
display: grid;
place-items: center;
width: 48px;
height: 44px;
overflow: hidden;
}
.num {
font: 600 var(--text-2xl) / 1 var(--font-display);
font-variant-numeric: tabular-nums;
color: var(--stage-ink);
animation: in-up 280ms var(--ease-out);
}
.num[data-dir='down'] { animation-name: in-down; }
@keyframes in-up {
from { transform: translateY(60%); opacity: 0; }
}
@keyframes in-down {
from { transform: translateY(-60%); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.num { animation: none; }
}