Number Input
stimeo--number-input
A spinbutton: step increment/decrement, range clamping, and bound-disabled buttons.
The stimeo--number-input controller implements the APG Spinbutton pattern. It owns the step logic itself (rather than the browser's native stepping) so behavior is identical for a native <input type="number"> and a custom role="spinbutton" host. Arrows step by step, PageUp/PageDown by pageStep, Home/End jump to a finite min/max, and typed values are clamped and snapped on change. The increment and decrement buttons are disabled at the bounds (focus returns to the input first), and stimeo--number-input:change is dispatched on every committed change.
Keyboard
| Key | Action |
|---|---|
| ↑ / ↓ | Increase / decrease by one step. |
| PageUp / PageDown | Increase / decrease by the page step (default step × 10). |
| Home / End | Jump to the minimum / maximum. |
<%# Markup for the number-input (APG Spinbutton) demo.
Increment/decrement buttons and the arrow keys step by `step`, clamped to min/max.
The input is the only tab stop (buttons are tabindex="-1"); a button is disabled at
its boundary. %>
<div class="number-input-demo" data-controller="stimeo--number-input"
data-stimeo--number-input-min-value="0"
data-stimeo--number-input-max-value="10"
data-stimeo--number-input-step-value="1">
<button type="button" class="number-input-demo__btn"
aria-label="<%= t('components.number_input.demo.decrease') %>" tabindex="-1"
data-stimeo--number-input-target="decrement"
data-action="click->stimeo--number-input#decrement">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<input type="number" class="number-input-demo__input" min="0" max="10" step="1" value="1"
aria-label="<%= t('components.number_input.demo.quantity') %>"
data-stimeo--number-input-target="input"
data-action="change->stimeo--number-input#onInput
keydown->stimeo--number-input#onKeydown" />
<button type="button" class="number-input-demo__btn"
aria-label="<%= t('components.number_input.demo.increase') %>" tabindex="-1"
data-stimeo--number-input-target="increment"
data-action="click->stimeo--number-input#increment">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
/*
* Presentation-only styles for the number-input demo.
* Disabling at the boundary is expressed with native :disabled (the library sets disabled).
*/
.number-input-demo {
display: inline-flex;
align-items: stretch;
border: 1px solid var(--border-interactive);
border-radius: 0.375rem;
overflow: hidden;
}
.number-input-demo__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
color: var(--fg);
background: var(--bg);
border: 0;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.number-input-demo__btn:hover:not(:disabled) {
background: var(--color-primary-soft);
color: var(--accent);
}
.number-input-demo__btn:disabled {
color: var(--slate-300);
cursor: not-allowed;
}
.number-input-demo__input {
width: 3.5rem;
padding: 0.5rem 0.25rem;
font-size: 1rem;
text-align: center;
color: var(--fg);
background: var(--bg);
border: 0;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
/* Hide the native spin buttons (this demo uses its own buttons). */
-moz-appearance: textfield;
appearance: textfield;
}
.number-input-demo__input::-webkit-outer-spin-button,
.number-input-demo__input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.number-input-demo__input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.number-input-demo__btn .demo-icon {
width: 1.1rem;
height: 1.1rem;
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--number-input"
Targets
| Name | Description | Attribute |
|---|---|---|
input
required
|
The number field (native input or role=spinbutton host); the sole Tab stop and the source/sink of the value. |
data-stimeo--number-input-target="input" |
increment
|
The step-up button; steps by step and is disabled at the max bound. |
data-stimeo--number-input-target="increment" |
decrement
|
The step-down button; steps by step and is disabled at the min bound. |
data-stimeo--number-input-target="decrement" |
Values
| Name | Description | Attribute |
|---|---|---|
min
|
Lower bound for clamping (default -Infinity); also the snap-grid anchor and the Home target. | data-stimeo--number-input-min-value |
max
|
Upper bound for clamping (default +Infinity); the End target. | data-stimeo--number-input-max-value |
step
|
Increment per arrow/button step (default 1); also the snap grid. | data-stimeo--number-input-step-value |
pageStep
|
Increment for PageUp/PageDown; when 0 (default) falls back to step × 10. | data-stimeo--number-input-page-step-value |
Actions
| Name | Description | Action |
|---|---|---|
decrement
|
Decreases the value by one step and keeps focus on the input. | stimeo--number-input#decrement |
increment
|
Increases the value by one step and keeps focus on the input. | stimeo--number-input#increment |
onInput
|
On change, clamps and snaps the typed value to the step grid. | stimeo--number-input#onInput |
onKeydown
|
Handles APG spinbutton keys: Arrow steps, Page steps, Home/End jump to min/max. | stimeo--number-input#onKeydown |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched on every committed change, with the new value in detail. | stimeo--number-input:change |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
value |
Input | The clamped, step-snapped current value. |
disabled |
Increment / Decrement | Present on the button whose direction is out of range. |