data-stepper

Add custom increment and decrement buttons to number inputs. Respects min, max, and step attributes with automatic button disabling at boundaries.

Overview

The data-stepper attribute enhances a native <input type="number"> with visible increment and decrement buttons. Just add the attribute — the script reads min, max, and step from the input and builds the controls automatically.

<form-field>\n <label for="qty">Quantity</label>\n <input type="number" id="qty" min="0" max="50" step="1" value="1" data-stepper>\n</form-field>

How It Works

Add data-stepper to any <input type="number">. The init script:

  1. Wraps the input in a .number-wrapper container
  2. Inserts a decrease button (with aria-label="Decrease") before the input
  3. Inserts an increase button (with aria-label="Increase") after the input
  4. Sets both buttons to tabindex="-1" so the input itself keeps keyboard focus
  5. Reads min, max, and step attributes for boundary logic
  6. Disables the decrease button when at min, the increase button when at max
  7. Sets data-stepper-init to prevent double-binding

The underlying input remains a real form control. It submits with the form, supports validation, and native keyboard arrows still increment and decrement as expected.

Attributes

Attribute Values Description
data-stepper boolean Enables the stepper enhancement on a number input.
data-stepper-init boolean Set automatically to prevent double-binding. Do not set manually.
min number Standard HTML attribute. The decrease button is disabled at this value.
max number Standard HTML attribute. The increase button is disabled at this value.
step number Standard HTML attribute. Controls the increment/decrement amount per click.

Decimal Steps

When step is a decimal value, the stepper uses toFixed() to maintain precision. No floating-point drift — values stay clean.

<form-field>\n <label for="weight">Weight (kg)</label>\n <input type="number" id="weight" min="0" max="10" step="0.5" value="1.0" data-stepper>\n</form-field>

Custom Ranges

Combine min, max, and step for any numeric range. Buttons disable automatically at boundaries.

<form-field>\n <label for="temp">Temperature (°C)</label>\n <input type="number" id="temp" min="-20" max="45" step="5" value="20" data-stepper>\n</form-field>

No Min/Max

Without min or max, neither button ever disables. The stepper allows unbounded incrementing and decrementing.

<form-field>\n <label for="offset">Offset</label>\n <input type="number" id="offset" step="1" value="0" data-stepper>\n</form-field>

Button State at Boundaries

The stepper automatically manages button states based on the current value:

  • When the value equals min, the decrease button gets disabled
  • When the value equals max, the increase button gets disabled
  • Button states update on every step, keyboard change, and manual input
  • Disabled buttons use reduced opacity and cursor: not-allowed

Events

Clicking a stepper button fires both input and change events on the underlying input, matching the behavior of native keyboard arrows.

Event Target Description
input The <input> Fired immediately when a stepper button is clicked.
change The <input> Fired after the value changes, matching native behavior.
const input = document.querySelector('[data-stepper]'); input.addEventListener('change', (e) => { console.log('New value:', e.target.value); }); input.addEventListener('input', (e) => { console.log('Stepping to:', e.target.value); });

In a Form

Steppers work naturally inside forms. Combine multiple steppers for booking or quantity selection interfaces.

<form class="stacked"> <form-field> <label for="adults">Adults</label> <input type="number" id="adults" min="1" max="10" step="1" value="2" data-stepper> </form-field> <form-field> <label for="children">Children</label> <input type="number" id="children" min="0" max="8" step="1" value="0" data-stepper> </form-field> <button type="submit">Search</button> </form>

Styling

The .number-wrapper container and its buttons are styled via CSS. All styles are gated on [data-stepper-init] so the input renders normally without JavaScript.

/* Wrapper around input and buttons */ .number-wrapper { display: inline-flex; align-items: center; border: 1px solid var(--color-border); border-radius: var(--radius-m); } /* Stepper buttons */ .number-wrapper button { background: var(--color-surface-raised); border: none; cursor: pointer; padding: var(--size-xs) var(--size-s); } /* Disabled state at boundaries */ .number-wrapper button:disabled { opacity: 0.4; cursor: not-allowed; }

The wrapper uses inline-flex for alignment and rounds the outer corners. Button disabled states reduce opacity for clear visual feedback.

Dynamic Elements

Inputs added to the DOM after page load are automatically enhanced via a MutationObserver. No manual initialization is needed.

// Dynamically added steppers are auto-enhanced via MutationObserver const input = document.createElement('input'); input.type = 'number'; input.min = '0'; input.max = '100'; input.value = '50'; input.dataset.stepper = ''; document.body.appendChild(input); // input is wrapped and ready — no manual init needed

Accessibility

  • Stepper buttons have aria-label="Decrease" and aria-label="Increase" for screen readers
  • Buttons use tabindex="-1" — the input itself retains keyboard focus, avoiding extra tab stops
  • Native keyboard arrows (Up/Down) still work for stepping, preserving expected input behavior
  • The input remains a real <input type="number">, so screen readers announce it correctly
  • Disabled buttons are conveyed to assistive technology via the native disabled attribute
  • Without JavaScript, the input renders as a standard number field (progressive enhancement)