SpinBox — HTML & CSS
A numeric stepper with + / − buttons, hold-to-repeat, min/max clamping, decimal support and a screen-reader-friendly value. Configured entirely through data-* attributes on the markup.
Wiring it up in JS (init, events, API, Vue) is on the JavaScript page.
Live example
Interact with these — type, use the arrows, hold a button, or press Shift while clicking for a bigger step. The readout shows the ui-spinbox-change event the component emits.
Color
Decimals — data-decimals sets the displayed precision and the step (1/10ⁿ):
Size — xsm · sm · default · lg
Shape (no color) — r-0 · default · r-round
Width — expand drops the fixed track and fills the parent. The + / − buttons keep their square size, so the input in the middle takes all the extra width:
Disabled — data-disabled in markup, rendered inert:
Reactive disable — toggling :data-disabled re-inits via destroy() → scan() (the typed value survives):
Last ui-spinbox-change: —
Markup
A SpinBox is a .UIsp container holding two .UIsp__btn buttons around a .UIsp__input. Behavior is configured with data-* attributes:
<div class="UIsp primary" data-min="0" data-max="100"
role="spinbutton" tabindex="0" aria-label="Volume">
<button class="UIsp__btn" type="button" aria-label="Decrease">−</button>
<input class="UIsp__input" id="volume" type="text" value="50"
aria-label="Current value" inputmode="decimal" />
<button class="UIsp__btn" type="button" aria-label="Increase">+</button>
</div>Minimal markup
The smallest working SpinBox — no color, no bounds, no label:
<div class="UIsp" role="spinbutton" tabindex="0" aria-label="Value">
<button class="UIsp__btn" type="button" aria-label="Decrease">−</button>
<input class="UIsp__input" type="text" value="0"
aria-label="Current value" inputmode="decimal" />
<button class="UIsp__btn" type="button" aria-label="Increase">+</button>
</div>Width — expand
A SpinBox has a fixed track by default: 13rem, or 6rem / 10rem / 16rem for xsm / sm / lg. It also refuses to shrink inside a flex row, so the input never collapses.
Add expand and it gives that up: the control fills its parent instead. The + / − buttons keep their square size — they sit in min-content grid columns — so every extra pixel goes to the input in the middle.
<!-- as wide as the parent, whatever that is -->
<div class="UIsp primary expand" data-min="0" data-max="100"
role="spinbutton" tabindex="0" aria-label="Amount">
<button class="UIsp__btn" type="button" aria-label="Decrease">−</button>
<input class="UIsp__input" id="amount" type="text" value="0"
aria-label="Current value" inputmode="decimal" />
<button class="UIsp__btn" type="button" aria-label="Increase">+</button>
</div>It stacks with the size modifiers — lg expand keeps the large height and buttons, only the width becomes fluid:
<div class="UIsp lg expand" …>…</div>Inside a flex row, add flex: 1 to the element so it takes the leftover space rather than the full width of the row:
<div style="display: flex; gap: 1rem">
<div class="UIsp expand" style="flex: 1" …>…</div>
<div class="UIsp" …>…</div> <!-- stays at its fixed 13rem -->
</div>Options (data-*)
| Attribute | Meaning |
|---|---|
data-min | Lower bound (default 0). |
data-max | Upper bound. 0 (default) means no upper limit. |
data-decimals | Number of decimal places to display (alias: data-step). |
data-unit | Unit appended to the screen-reader value, e.g. "kg" → 2.5 kg. |
data-negative | Opt in to negative values (positive-only without it). |
data-disabled | Render the control disabled. |