Range Group

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

.range-group {
  display: flex;
  flex-direction: column;
  gap: spacer('xs');

  + .range-group {
    margin-block-start: spacer('l');
  }

  &__label {
    align-items: baseline;
    display: flex;
    flex-wrap: wrap;
    gap: spacer('xs');
    justify-content: space-between;
  }

  &__value {
    color: color('primary');
    font-variant-numeric: tabular-nums;
    font-weight: 700;
  }

  &__inner {
    align-items: center;
    display: flex;
    gap: spacer('xs');

    .form-range {
      flex-grow: 1;
      margin-block-start: 0;
    }

    .btn {
      flex-shrink: 0;
    }
  }
}
<div class="range-group" data-range-group data-unit-one="seat" data-unit-many="seats">
    <div class="range-group__label">
        <label class="form-label" for="seats">Seats</label>
        <output class="range-group__value" for="seats" aria-hidden="true"></output>
    </div>
    <div class="range-group__inner">
        <button class="btn btn--primary btn--sm btn--icon" type="button" aria-controls="seats" data-step="-1">
            <span class="sr-only">Decrease seats</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="form-range range-group__control" id="seats" type="range" min="1" max="50" value="5" step="1">
        <button class="btn btn--primary btn--sm btn--icon" type="button" aria-controls="seats" data-step="1">
            <span class="sr-only">Increase seats</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>
</div>
<div class="range-group" data-range-group data-unit-one="GB" data-unit-many="GB">
    <div class="range-group__label">
        <label class="form-label" for="storage">Storage</label>
        <output class="range-group__value" for="storage" aria-hidden="true"></output>
    </div>
    <div class="range-group__inner">
        <button class="btn btn--primary btn--sm btn--icon" type="button" aria-controls="storage" data-step="-1">
            <span class="sr-only">Decrease storage</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="form-range range-group__control" id="storage" type="range" min="10" max="500" value="100" step="10">
        <button class="btn btn--primary btn--sm btn--icon" type="button" aria-controls="storage" data-step="1">
            <span class="sr-only">Increase storage</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>
</div>
document.querySelectorAll('[data-range-group]').forEach((group) => {
  const control = group.querySelector('input[type="range"]');
  const output = group.querySelector('output');
  const buttons = group.querySelectorAll('[data-step]');
  const min = Number(control.min || 0);
  const max = Number(control.max || 100);
  const plural = new Intl.PluralRules(document.documentElement.lang || 'en');

  const update = () => {
    const value = Number(control.value);
    const unit = plural.select(value) === 'one' ? group.dataset.unitOne : group.dataset.unitMany;
    const text = `${value} ${unit}`;

    // The visible output is for sighted users; aria-valuetext gives the same text to screen readers.
    output.textContent = text;
    control.setAttribute('aria-valuetext', text);
    buttons.forEach((button) => {
      button.disabled = Number(button.dataset.step) < 0 ? value <= min : value >= max;
    });
  };

  buttons.forEach((button) => {
    button.addEventListener('click', () => {
      Number(button.dataset.step) < 0 ? control.stepDown() : control.stepUp();
      update();
      control.dispatchEvent(new Event('change', { bubbles: true }));
    });
  });

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

Range Group

A range slider with a live value display and step buttons, for choosing amounts like seats, storage or a budget.

Anatomy

  • .range-group with data-unit-one and data-unit-many (the unit in singular and plural).
  • .range-group__label holds the label and an output (__value) that shows the current value.
  • .range-group__inner puts the decrease and increase buttons around the native range input (.form-range).

Accessibility

  • The range input gets an aria-valuetext with the unit ("5 seats"), so screen readers read a meaningful value instead of a bare number. The visible output is hidden from them to avoid hearing it twice.
  • The step buttons have visually hidden labels ("Increase seats") and point to the input with aria-controls. At the limits, they become disabled.
  • The range input keeps its own keyboard support (arrows, Page Up/Page Down, Home/End); the buttons help touch and mouse users make precise steps.

Technical Details

  • The buttons use the native stepUp() and stepDown(), which respect step, min and max; a change event fires after each press.
  • The unit is picked with Intl.PluralRules, in the page language.
  • It's built from Spruce's form range and button styles, so it follows every style and the dark mode.

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.