A number input between a decrement and an increment button, for a value that is edited in fixed jumps but stays typeable.
When to Use
Use when:
The value has a natural jump size and a bounded range — a duration in fives, a participant count, a number of rounds. The buttons make the common edit one click; the field keeps the uncommon one possible. It replaces the hand-rolled app-contribute__stepper and the planner's duration-stepper.
Don't use when:
Any number is as likely as any other, or the range is wide — a year, a price, a percentage. That is a plain Input of type number. For a handful of fixed choices use Select.
Variants
Bare
Without a label the input has no accessible name, so only use this form beside visible text in your own markup, and point attrs => ['aria-label' => …] at it.
Buttons on --gray-100, the number centred and semibold.
Hover
A button takes the brand tint (--brand-tint) with --brand-ink ink.
Focus
The real <input type="number"> keeps the platform focus ring; the buttons are real <button type="button"> elements and keep theirs.
At a bound
Nothing visual: the page's handler clamps to data-min / data-max. Disable a button yourself if you want the bound to show.
Real-World Usage
The exercise contribution form. No component here ships JavaScript, so the page wires one delegated handler to the hooks the component renders — which is enough for every stepper on the page.
min
<?= ws_stepper('duration', ['label' => 'Duration', 'id' => 'ceDuration',
'value' => 30, 'min' => 5, 'max' => 480, 'step' => 5, 'unit' => 'min']) ?>
<script>
document.addEventListener('click', function (event) {
var btn = event.target.closest('[data-ws-stepper-dec], [data-ws-stepper-inc]');
if (!btn) return;
var box = btn.closest('[data-ws-stepper]');
var input = box.querySelector('input');
var size = parseInt(box.dataset.step, 10) || 1;
var next = (parseInt(input.value, 10) || 0) + (btn.hasAttribute('data-ws-stepper-dec') ? -size : size);
var min = parseInt(box.dataset.min, 10), max = parseInt(box.dataset.max, 10);
if (!isNaN(min)) next = Math.max(min, next);
if (!isNaN(max)) next = Math.min(max, next);
input.value = next;
input.dispatchEvent(new Event('change', { bubbles: true }));
});
</script>
Options
Option
Type
Default
Purpose
$name
string
''
Positional. The input's name.
label
string
null
Field label. When set, the component renders the ws-field wrapper and binds the label to the input.
value
int|string
0
Initial value
min / max
int|string
null
Bounds. Applied to the input and exposed as data-min / data-max for the page's handler.
step
int|string
1
Jump per click. Applied to the input and exposed as data-step.
unit
string
null
Trailing unit text inside the row
id
string
'ws-stepper-' . uniqid()
Input ID. Set it when the page script needs a stable handle.
class
string
''
Additional CSS classes on the row
attrs
array
[]
Extra HTML attributes on the number input
decreaseLabel / increaseLabel
string
"Decrease by 5 min"
aria-labels for the two buttons, derived from step and unit unless given
Accessibility
Concern
Behavior
Semantics
A real <input type="number"> with min, max and step, so the keyboard's arrow keys already step it before any script runs.
Buttons
Real <button type="button"> elements via Icon Button, each with an aria-label that names the jump ("Increase by 5 min"), not the icon.
Labelling
Pass label and the input is named through a <label for>. Without it, name the input yourself through attrs.
Hidden spinners
The native spin buttons are hidden with appearance: textfield. They were a second control at a second step size; the arrow keys still work.
Tokens
Token
Used for
--space-2
Gap between buttons, field and unit
--gray-100
Button fill at rest
--brand-tint / --brand-ink
Button fill and ink on hover
--color-ink-secondary
Button ink at rest, and the unit
--font-semibold
The number
--text-caption
The unit
CSS Classes
Class
Purpose
.ws-stepper
The row. Carries data-ws-stepper, data-step, data-min, data-max.
.ws-stepper__btn
Either button. Carries data-ws-stepper-dec or data-ws-stepper-inc.
.ws-stepper__field
The ws_input wrapper, flattened into one cell of the row
.ws-stepper__unit
The unit text
Files
File
Purpose
includes/components/helpers.php
ws_stepper() helper function
includes/components/stepper.php
Template — the row, the two icon buttons and the input