Number Group

Maximum 10 per order.

@use 'sprucecss/scss/spruce' as *;

@include generate-form-control(
  '.number-group',
  false,
  false,
  false
);

.number-group {
  align-items: center;
  display: inline-flex;
  gap: spacer('xxs');
  inline-size: auto;
  max-inline-size: 8rem;
  padding: 0.25em;
  position: relative;

  &:focus-within {
    @include short-ring('input');
  }

  &__input {
    appearance: textfield;
    background-color: transparent;
    border: 0;
    color: color('text', 'form');
    font-size: config('font-size', $form-control);
    font-variant-numeric: tabular-nums;
    font-weight: config('font-weight', $form-control);
    inline-size: 100%;
    line-height: config('line-height', $form-control);
    min-inline-size: 2ch;
    outline: 0;
    padding: 0;
    text-align: center;

    &::-webkit-outer-spin-button,
    &::-webkit-inner-spin-button {
      appearance: none;
      margin: 0;
    }
  }

  &__decrement,
  &__increment {
    border-radius: config('border-radius', $form-control-sm, false);
    flex-shrink: 0;

    &:disabled {
      cursor: not-allowed;
    }
  }
}
<div class="form-group">
    <label class="form-label" for="quantity">Quantity</label>
    <div class="number-group" data-number-group>
        <button class="btn btn--primary btn--sm btn--icon number-group__decrement" type="button" aria-controls="quantity" data-step="-1">
            <span class="sr-only">Decrease quantity</span>
            <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5 12h14"/></svg>
        </button>
        <input class="number-group__input" id="quantity" type="number" inputmode="numeric" name="quantity" value="1" min="1" max="10" step="1" aria-describedby="quantity-help">
        <button class="btn btn--primary btn--sm btn--icon number-group__increment" type="button" aria-controls="quantity" data-step="1">
            <span class="sr-only">Increase quantity</span>
            <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 5v14M5 12h14"/></svg>
        </button>
    </div>
    <p class="form-description" id="quantity-help">Maximum 10 per order.</p>
</div>
document.querySelectorAll('[data-number-group]').forEach((group) => {
  const input = group.querySelector('input');
  const buttons = group.querySelectorAll('[data-step]');
  const min = Number(input.min || -Infinity);
  const max = Number(input.max || Infinity);

  // Keep the value in range and disable the button that can't go further.
  const update = () => {
    const value = Math.min(max, Math.max(min, Number(input.value) || min));

    input.value = value;
    buttons.forEach((button) => {
      button.disabled = Number(button.dataset.step) < 0 ? value <= min : value >= max;
    });
  };

  buttons.forEach((button) => {
    button.addEventListener('click', () => {
      // stepUp/stepDown respect the step, min and max attributes.
      Number(button.dataset.step) < 0 ? input.stepDown() : input.stepUp();
      update();
      input.dispatchEvent(new Event('change', { bubbles: true }));
    });
  });

  input.addEventListener('change', update);
  update();
});

Number Group

A quantity input with decrease and increase buttons, built on a native number input, so the keyboard, the form submission and the validation work as usual.

Anatomy

  • .number-group wraps the controls and draws the field (it's generated with the generate-form-control mixin, so it looks like any other input).
  • .number-group__input is a native input type="number" with min, max and step.
  • .number-group__decrement and .number-group__increment are icon buttons pointing to the input with aria-controls.

Accessibility

  • The input has a visible label, and the help text is linked with aria-describedby.
  • The icon buttons have visually hidden text ("Decrease quantity", "Increase quantity").
  • At the limits, the matching button gets disabled, so it's clear why nothing happens.
  • The ↑ / ↓ keys still work in the input; the buttons are an extra, not a replacement.

Technical Details

  • The buttons use the native stepUp() and stepDown() methods, which respect step, min and max.
  • A typed value outside the range is corrected on change, and a bubbling change event fires after every button press, so the rest of the page (like a cart total) can react.
  • The browser spin buttons are hidden; the numbers use tabular-nums, so the width doesn't jump.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.