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">−</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.