Number Input
stimeo--number-input
A field for stepping a number up and down that never leaves its range.
Steps a number by the amount you choose. The arrow keys move one step, the page keys move further, and Home and End jump to the lowest and highest values. A typed value is brought into range and snapped to the step as well. The stepping is worked out here rather than left to the browser, so a native number field and a custom one behave identically. A button reaching the end of the range stops being pressable, and focus moves back into the field first so the keyboard never loses its place. Every settled value is reported.
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-700);
}
.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 user edit, with the new value in detail. | stimeo--number-input:change |
reconcile
|
Fires when a runtime min/max/step change or an input swap moves the committed value; same detail as change. | stimeo--number-input:reconcile |
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. |