Skip to content

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.

Theme:

Color

default
danger
info
primary
success
warning

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:

parent 18rem
full-width parent — lg expand keeps the large height

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:

html
<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:

html
<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.

html
<!-- 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:

html
<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:

html
<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-*)

AttributeMeaning
data-minLower bound (default 0).
data-maxUpper bound. 0 (default) means no upper limit.
data-decimalsNumber of decimal places to display (alias: data-step).
data-unitUnit appended to the screen-reader value, e.g. "kg"2.5 kg.
data-negativeOpt in to negative values (positive-only without it).
data-disabledRender the control disabled.