Component Library

← Back to gallery

Number stepper

Category: Forms · Tags: number, stepper, quantity, increment, min-max

Between 1 and 8 guests.

How to use it

Use for small whole-number quantities that people usually change by one or two, such as guests, seats or items in a cart. For large or precise numbers (prices, years) use a plain text input with inputmode="numeric", and for an approximate value on a scale use the range slider. Change min, max, the hint, the button names and the ids; always validate the number on the server too.

Accessibility

A native type="number" input with a <label for> and a hint (aria-describedby) stating the 1 to 8 range, so people can also type a value or use the arrow keys; min and max are enforced when the value changes. The − and + controls are native <button>s named "Remove one guest" and "Add one guest" with aria-controls; at a limit they get aria-disabled="true" and do nothing rather than the disabled attribute, so focus is never dropped, and a visually hidden role="status" announces the new value ("3 guests") after each press. All controls show a 2px focus outline. Text is #0f172a on #ffffff (17.85:1), #0f172a on the #e2e8f0 hover (14.48:1), the hint #475569 on #ffffff (7.58:1) and the at-limit buttons #475569 on #f1f5f9 (6.92:1).

Source (copy-ready)

<div class="dcl-stepper">
  <label class="dcl-stepper__label" for="dcl-stepper-guests">Guests</label>
  <p class="dcl-stepper__hint" id="dcl-stepper-hint">Between 1 and 8 guests.</p>
  <div class="dcl-stepper__row">
    <button type="button" class="dcl-stepper__btn" data-step="-1" aria-controls="dcl-stepper-guests" aria-label="Remove one guest">&minus;</button>
    <input class="dcl-stepper__input" id="dcl-stepper-guests" name="guests" type="number" inputmode="numeric" min="1" max="8" step="1" value="2" aria-describedby="dcl-stepper-hint">
    <button type="button" class="dcl-stepper__btn" data-step="1" aria-controls="dcl-stepper-guests" aria-label="Add one guest">+</button>
  </div>
  <p class="dcl-stepper__live" role="status"></p>
</div>
<style>
.dcl-stepper { display: flex; flex-direction: column; gap: 0.35rem; max-width: 20rem; font: inherit; color: #0f172a; background-color: #ffffff; }
.dcl-stepper__label { font-weight: 600; color: #0f172a; background-color: #ffffff; }
.dcl-stepper__hint { margin: 0; font-size: 0.9rem; color: #475569; background-color: #ffffff; }
.dcl-stepper__row { display: flex; align-items: stretch; gap: 0.35rem; }
.dcl-stepper__btn { width: 2.75rem; min-height: 2.75rem; padding: 0; border: 1px solid #64748b; border-radius: 0.375rem; cursor: pointer; font: inherit; font-size: 1.25rem; font-weight: 700; color: #0f172a; background-color: #ffffff; }
.dcl-stepper__btn:hover { color: #0f172a; background-color: #e2e8f0; }
.dcl-stepper__btn:focus-visible { outline: 2px solid #1d4ed8; outline-offset: 2px; }
.dcl-stepper__btn[aria-disabled="true"] { cursor: not-allowed; border-style: dashed; color: #475569; background-color: #f1f5f9; }
.dcl-stepper__input { width: 4.5rem; min-width: 0; min-height: 2.75rem; padding: 0 0.5rem; border: 1px solid #64748b; border-radius: 0.375rem; text-align: center; font: inherit; color: #0f172a; background-color: #ffffff; }
.dcl-stepper__input:focus-visible { outline: 2px solid #1d4ed8; outline-offset: 1px; }
.dcl-stepper__live { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; color: #0f172a; background-color: #ffffff; }
</style>
<script>
(function () {
  document.querySelectorAll(".dcl-stepper").forEach(function (root) {
    if (root.dataset.dclBound) { return; }
    root.dataset.dclBound = "true";
    var input = root.querySelector(".dcl-stepper__input");
    var live = root.querySelector(".dcl-stepper__live");
    var buttons = root.querySelectorAll(".dcl-stepper__btn");
    if (!input) { return; }
    var min = parseInt(input.min, 10);
    var max = parseInt(input.max, 10);
    if (isNaN(min)) { min = 0; }
    if (isNaN(max)) { max = 99; }
    function clamp(value) {
      if (isNaN(value)) { value = min; }
      return Math.min(max, Math.max(min, value));
    }
    function sync() {
      var value = parseInt(input.value, 10);
      buttons.forEach(function (button) {
        var step = parseInt(button.getAttribute("data-step"), 10) || 0;
        var atLimit = (step < 0 && value <= min) || (step > 0 && value >= max);
        if (atLimit) { button.setAttribute("aria-disabled", "true"); } else { button.removeAttribute("aria-disabled"); }
      });
    }
    buttons.forEach(function (button) {
      button.addEventListener("click", function () {
        if (button.getAttribute("aria-disabled") === "true") { return; }
        var step = parseInt(button.getAttribute("data-step"), 10) || 0;
        var value = clamp(parseInt(input.value, 10) + step);
        input.value = value;
        sync();
        if (live) { live.textContent = value + (value === 1 ? " guest" : " guests"); }
      });
    });
    input.addEventListener("change", function () {
      input.value = clamp(parseInt(input.value, 10));
      sync();
    });
    sync();
  });
})();
</script>

Was this useful? Send feedback.