ui-017

Quantity Stepper

£10 or the whole Vault, £99

A quantity stepper for carts and booking forms: digits roll like an odometer, with hold to repeat and a shake at the limits.

intermediate
4 more details
micro-interactionodometer-counterform-animationclick-state

This element reads better at your own screen size than in the frame below:

Updated

About this component

A quantity stepper whose number does not simply change: each digit that differs rolls vertically like an odometer, the outgoing character sliding out of the slot while the incoming one arrives from the other side. The direction follows the button, so counting down rolls the opposite way to counting up.

Read the full effect overview

A single self-contained component: one HTML file, one stylesheet, one script, vanilla JavaScript and GSAP core, no framework and no plugins. A native input type="number" stays in the DOM and holds the value, so the stepper submits with an ordinary form and reads correctly to a screen reader, and every change dispatches a bubbling change event for a cart or total to listen to. Every .stepper on the page is initialised independently, so a list of order lines can carry one each. Ships in a light and a dark theme, switched with one data-variant attribute.

Quantity Stepper - UI component preview

What's included

9 items
  • Per-digit vertical roll, direction follows plus or minus
  • Only the digits that actually changed animate
  • Press squash on the buttons, released with a back.out spring
  • Hold a button to repeat, with a pause before the run starts
  • Buttons disable at min and max, with a refusal shake on the attempt
  • Native input type=number keeps the value for forms and screen readers
  • Typing into the field clamps to range and rolls to the new number
  • Any number of steppers on one page, found by class rather than id
  • Light and dark themes on one set of custom properties

Perfect for

4 use cases
  • Cart and checkout quantity controls
  • Seat, ticket and guest pickers on booking forms
  • Portion and serving counters on recipe or ordering screens
  • Any numeric setting where the change should be legible, not instant

How it works

3 sections

Slot per character

The display is one overflow-hidden slot per character. On a change the new character is appended to the slot, set to yPercent 100 or -100 depending on direction and tweened to 0, while the outgoing span leaves the other way and removes itself onComplete. Slots whose character is unchanged are skipped, so 19 to 20 rolls two digits and 10 to 11 rolls one.

Squash and refusal

A press tweens the button to scaleY 0.86 and scaleX 1.08 and releases it with back.out(4). At a limit the button takes the disabled attribute and pointer-events none, and a press inside its rectangle runs a short elastic x shake instead, so the refusal is visible rather than silent.

The input owns the value

Every path, button, hold repeat and keyboard, writes into the native number input and dispatches a bubbling change event, so a line total or a cart can recalculate from it. The rolled digits are aria-hidden decoration over the top of it, so assistive technology and form submission both see one plain number.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Quantity Stepper opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £99, one payment.

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles and the two theme token blocks

  • assets/script.js: readable, commented source with an onReady guard

  • Per-digit vertical roll, direction follows plus or minus

  • Only the digits that actually changed animate

  • Press squash on the buttons, released with a back.out spring

  • Hold a button to repeat, with a pause before the run starts

  • Buttons disable at min and max, with a refusal shake on the attempt

  • Native <input type="number"> keeps the value for forms and screen readers

  • Typing into the field clamps to range and rolls to the new number

  • Every .stepper on the page is initialised, so a list of order lines can carry one each

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

2. Add to your <body>:

Copy one .stepper block from index.html. The script finds its parts by class and by data-step, never by id, so you can put as many on a page as you like:

Code snippet omitted: it ships with the download.

.stepper, .stepper-row, .digits, .stepper-input and the two data-step buttons are the contract; keep those names. .stepper-legend is there if you want a visible label above the row instead of an aria-label.

3. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

The range comes from the input itself: min, max and value. There is nothing to configure in the script.

Every change writes to the native input and dispatches a bubbling change event, so a cart total, a price or a form can listen for it:

Code snippet omitted: it ships with the download.

Themes

The stepper ships in two themes, one design at two token values: light (the default) and dark. Set the attribute on <body>, or on any ancestor of the component in your own page:

Code snippet omitted: it ships with the download.

Nothing else changes: same markup, same script, and no variant name is ever read in JavaScript.

Both blocks live at the top of assets/style.css. The ground names (--ground, --ground-2, --raised, --hover, --ink, --ink-2, --ink-3, --line, --accent, --shadow, --radius*) are the demo page's as well as the component's; the component's own names are --row-bg, --row-border, --row-radius, --row-pad, --btn-bg, --btn-fg, --btn-border, --btn-radius, --btn-hover, --btn-press, --btn-press-fg, --btn-disabled-fg, --num-fg, --num-weight and --focus. To wear your own brand, re-value that second list (or point each one at a token you already have); the accent here is #ff4d00 and it is used only for the press flash, the focus ring and ::selection.

The press flash reads --btn-press, --btn-press-fg, --btn-bg and --btn-fg off the button with getComputedStyle at the moment it runs, so a re-valued theme never needs a JavaScript change.

Demo furniture. The toolbar strip across the top of index.html, and the order lines, prices and totals it sits above (everything with a stage- class, plus the small inline script that recalculates them), are the demo only. They are not part of what you bought and assets/script.js never references them; the component is .stepper and everything under it.

How It Works

Slot per character. The display is one overflow: hidden slot per character. On a change the new character is appended to its slot, set to yPercent: 110 (or -110 counting down) and tweened to 0, while the outgoing span leaves the other way and removes itself onComplete. Slots whose character is unchanged are skipped, so 19 to 20 rolls two digits and 10 to 11 rolls one.

Squash and refusal. A press tweens the button to scaleY: 0.86, scaleX: 1.08 and releases it with back.out(4), cross-fading the background to --btn-press and back. At a limit the button takes the disabled attribute and pointer-events: none; a disabled control receives no events of its own, so the row listens instead and shakes the button when a press lands inside its rectangle. The refusal is visible rather than silent.

Hold to repeat. pointerdown starts a 420ms timer, after which an interval steps every 110ms until pointerup, pointerleave, pointercancel or blur. The interval stops itself the moment the button disables at a limit.

The input owns the value. Every path, button, hold and keyboard, writes into the native number input and dispatches change. The rolled digits are aria-hidden decoration drawn over the top of a transparent-text input that is still focusable and still submits. Without JavaScript the .has-js gate never applies, the input is a plain visible number field, and the stepper degrades to the browser's own spinner.

Customisation

  • Colours, radii and borders live in assets/style.css as custom properties per theme (list above).
  • ROLL, HOLD_DELAY and HOLD_RATE at the top of assets/script.js are the roll duration and the hold repeat timing. Below about 0.3s the roll reads as a flicker rather than a movement.
  • The squash amounts are the first tween in squash(). Raise the back.out number for a snappier release.
  • The digit slot width is .digit { width: 0.62em }. Widen it for a face with broader numerals, or drop font-variant-numeric: tabular-nums if you want proportional digits and per-character slots sized by content.
  • Never add a CSS transform to .step-btn or to a digit span: GSAP owns their scale, x and yPercent, and a CSS transform underneath is parsed as a pixel offset that never clears.

Accessibility

  • The value is a native <input type="number"> with an aria-label, so it is announced, focusable, typeable and submitted with the form.
  • The rolling characters are aria-hidden="true", so the number is never read twice.
  • Minus and plus are real <button> elements with labels; Space and Enter work, and the disabled state is the real disabled attribute rather than a class.
  • Keyboard: Tab reaches minus, the field and plus in order; the field takes typed digits and the browser's own Up and Down arrows, and clamps to min/max on blur.
  • :focus-visible rings on the buttons and a :focus-within ring on the value, both in the accent.
  • prefers-reduced-motion: reduce replaces the roll, the squash and the shake with instant changes.

Requirements

  • GSAP 3.12+ (core only, no plugins)
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.