Canvas Design System
Main Site Tokens

Stepper

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.

<?= ws_stepper('rounds', ['value' => 3, 'min' => 1, 'max' => 10, 'attrs' => ['aria-label' => 'Rounds']]) ?>

With a label and a unit

The preferred form. The label renders the ws-field wrapper and binds to the input; the unit sits inside the row so the number reads as a quantity.

min
<?= ws_stepper('duration', [
    'label' => 'Duration',
    'value' => 30,
    'min'   => 5,
    'max'   => 480,
    'step'  => 5,
    'unit'  => 'min',
]) ?>

States

StateBehavior
DefaultButtons on --gray-100, the number centred and semibold.
HoverA button takes the brand tint (--brand-tint) with --brand-ink ink.
FocusThe real <input type="number"> keeps the platform focus ring; the buttons are real <button type="button"> elements and keep theirs.
At a boundNothing 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

OptionTypeDefaultPurpose
$namestring''Positional. The input's name.
labelstringnullField label. When set, the component renders the ws-field wrapper and binds the label to the input.
valueint|string0Initial value
min / maxint|stringnullBounds. Applied to the input and exposed as data-min / data-max for the page's handler.
stepint|string1Jump per click. Applied to the input and exposed as data-step.
unitstringnullTrailing unit text inside the row
idstring'ws-stepper-' . uniqid()Input ID. Set it when the page script needs a stable handle.
classstring''Additional CSS classes on the row
attrsarray[]Extra HTML attributes on the number input
decreaseLabel / increaseLabelstring"Decrease by 5 min"aria-labels for the two buttons, derived from step and unit unless given

Accessibility

ConcernBehavior
SemanticsA real <input type="number"> with min, max and step, so the keyboard's arrow keys already step it before any script runs.
ButtonsReal <button type="button"> elements via Icon Button, each with an aria-label that names the jump ("Increase by 5 min"), not the icon.
LabellingPass label and the input is named through a <label for>. Without it, name the input yourself through attrs.
Hidden spinnersThe native spin buttons are hidden with appearance: textfield. They were a second control at a second step size; the arrow keys still work.

Tokens

TokenUsed for
--space-2Gap between buttons, field and unit
--gray-100Button fill at rest
--brand-tint / --brand-inkButton fill and ink on hover
--color-ink-secondaryButton ink at rest, and the unit
--font-semiboldThe number
--text-captionThe unit

CSS Classes

ClassPurpose
.ws-stepperThe row. Carries data-ws-stepper, data-step, data-min, data-max.
.ws-stepper__btnEither button. Carries data-ws-stepper-dec or data-ws-stepper-inc.
.ws-stepper__fieldThe ws_input wrapper, flattened into one cell of the row
.ws-stepper__unitThe unit text

Files

FilePurpose
includes/components/helpers.phpws_stepper() helper function
includes/components/stepper.phpTemplate — the row, the two icon buttons and the input
includes/components/components.cssStyles (.ws-stepper rules)