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.

  • CSS
  • Forms & controls
  • Click
  • Added
Guests
2

AI prompt

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

Demo.tsx
'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>
  )
}
demo.module.css
.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; }
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.