Stock Indicator

Size

Only 3 left Order soon. Ships tomorrow.

  • Portland, Pearl District Available today
  • Seattle, Capitol Hill Limited stock
  • San Francisco, Mission Not available
@use 'sprucecss/scss/spruce' as *;

.stock-indicator {
  --stock-in: #{color('success', 'alert')};
  --stock-low: #{color('warning', 'alert')};
  --stock-out: #{color('danger', 'alert')};
  @include layout-stack('m');

  &__sizes {
    border: 0;
    display: flex;
    flex-wrap: wrap;
    gap: spacer('xs');
    margin: 0;
    padding: 0;
  }

  &__legend {
    color: color('heading');
    font-size: config('font-size-base', $typography);
    font-weight: 600;
    margin-block-end: spacer('xs');
    padding: 0;
  }

  &__size {
    cursor: pointer;
    position: relative;

    input {
      inset: 0;
      opacity: 0;
      position: absolute;
    }

    span {
      align-items: center;
      border: config('border-width-base', $display) solid color('border', 'form');
      border-radius: radius('md');
      color: color('heading');
      display: flex;
      font-weight: 600;
      justify-content: center;
      min-block-size: 2.75rem;
      min-inline-size: 3.25rem;
      padding-inline: spacer('s');
    }

    input:checked + span {
      background-color: color('heading');
      border-color: color('heading');
      color: color('background');
    }

    input:focus-visible + span {
      outline: 2px solid color('focus-ring');
      outline-offset: 2px;
    }

    // A sold-out size stays selectable (to ask for a notification), but looks crossed out.
    input[data-stock='0'] + span {
      background-image: linear-gradient(to top right, transparent calc(50% - 1px), color('border-strong') 50%, transparent calc(50% + 1px));
      color: color('muted');
    }

    input[data-stock='0']:checked + span {
      background-color: color('surface-muted');
      border-color: color('heading');
      color: color('heading');
    }
  }

  &__status {
    --stock-color: var(--stock-in);
    align-items: center;
    display: flex;
    gap: spacer('s');

    p {
      margin: 0;
    }

    strong {
      color: var(--stock-color);
      margin-inline-end: spacer('xxs');
    }

    span {
      color: color('muted');
    }

    &--low {
      --stock-color: var(--stock-low);
    }

    &--out {
      --stock-color: var(--stock-out);
    }
  }

  // Three bars: full, low (one) or out (none).
  &__level {
    display: flex;
    flex-shrink: 0;
    gap: 3px;

    span {
      background-color: var(--stock-color);
      block-size: 0.75rem;
      border-radius: 2px;
      inline-size: 0.4rem;
    }
  }

  &__status--low &__level span:nth-child(n + 2),
  &__status--out &__level span {
    background-color: color('border');
  }

  &__notify {
    background-color: color('surface-muted');
    border-radius: radius('md');
    padding: spacer('s');
    @include layout-stack('xs');

    &[hidden] {
      display: none;
    }
  }

  &__notify-row {
    display: flex;
    flex-wrap: wrap;
    gap: spacer('xs');

    .form-control {
      flex: 1 1 12rem;
    }
  }

  &__stores {
    border-block-start: config('border-width-base', $display) solid color('border');
    font-size: config('font-size-sm', $typography);
    list-style: none;
    padding: spacer('s') 0 0;
    @include layout-stack('xs');

    li {
      align-items: baseline;
      display: flex;
      gap: spacer('xs');
    }

    strong {
      color: color('heading');
      display: block;
    }
  }

  &__dot {
    block-size: 0.55rem;
    border-radius: radius('pill');
    flex-shrink: 0;
    inline-size: 0.55rem;

    &--in {
      background-color: var(--stock-in);
    }

    &--low {
      background-color: var(--stock-low);
    }

    &--out {
      background-color: var(--stock-out);
    }
  }
}
<section class="stock-indicator" aria-label="Availability" data-stock-indicator>
    <fieldset class="stock-indicator__sizes">
        <legend class="stock-indicator__legend">Size</legend>
        <label class="stock-indicator__size"><input type="radio" name="stock-size" value="S" data-stock="14"><span>S</span></label>
        <label class="stock-indicator__size"><input type="radio" name="stock-size" value="M" data-stock="3" checked><span>M</span></label>
        <label class="stock-indicator__size"><input type="radio" name="stock-size" value="L" data-stock="0"><span>L</span></label>
        <label class="stock-indicator__size"><input type="radio" name="stock-size" value="XL" data-stock="8"><span>XL</span></label>
    </fieldset>
    <div class="stock-indicator__status stock-indicator__status--low" aria-live="polite" data-stock-status>
        <span class="stock-indicator__level" aria-hidden="true"><span></span><span></span><span></span></span>
        <p><strong data-stock-text>Only 3 left</strong> <span data-stock-detail>Order soon. Ships tomorrow.</span></p>
    </div>
    <form class="stock-indicator__notify" action="#" method="post" hidden data-stock-notify>
        <label class="form-label" for="stock-notify-email">Get an email when it’s back</label>
        <div class="stock-indicator__notify-row">
            <input class="form-control" id="stock-notify-email" name="email" type="email" autocomplete="email" placeholder="you@example.com" required>
            <button class="btn btn--secondary" type="submit">Notify me</button>
        </div>
    </form>
    <ul class="stock-indicator__stores" role="list" aria-label="Pick up in a store">
        <li><span class="stock-indicator__dot stock-indicator__dot--in" aria-hidden="true"></span><span><strong>Portland, Pearl District</strong> Available today</span></li>
        <li><span class="stock-indicator__dot stock-indicator__dot--low" aria-hidden="true"></span><span><strong>Seattle, Capitol Hill</strong> Limited stock</span></li>
        <li><span class="stock-indicator__dot stock-indicator__dot--out" aria-hidden="true"></span><span><strong>San Francisco, Mission</strong> Not available</span></li>
    </ul>
</section>
(() => {
  // The low stock threshold: from this amount, the indicator warns.
  const low = 5;

  document.querySelectorAll('[data-stock-indicator]').forEach((indicator) => {
    const status = indicator.querySelector('[data-stock-status]');
    const text = indicator.querySelector('[data-stock-text]');
    const detail = indicator.querySelector('[data-stock-detail]');
    const notify = indicator.querySelector('[data-stock-notify]');

    const update = (input) => {
      const stock = Number(input.dataset.stock);
      const state = stock === 0 ? 'out' : stock <= low ? 'low' : 'in';

      status.className = `stock-indicator__status stock-indicator__status--${state}`;
      text.textContent = { in: 'In stock', low: `Only ${stock} left`, out: 'Out of stock' }[state];
      detail.textContent = {
        in: `Size ${input.value} ships tomorrow.`,
        low: `Size ${input.value}: order soon. Ships tomorrow.`,
        out: `Size ${input.value} is sold out.`
      }[state];
      notify.hidden = state !== 'out';
    };

    indicator.addEventListener('change', (event) => {
      if (event.target.matches('[data-stock]')) update(event.target);
    });

    notify.addEventListener('submit', (event) => {
      event.preventDefault();

      if (!notify.reportValidity()) return;

      detail.textContent = `We’ll email ${notify.elements.email.value} when it’s back.`;
      notify.hidden = true;
      // The form is gone; the status keeps the focus context.
      indicator.querySelector('[data-stock]:checked').focus();
    });

    update(indicator.querySelector('[data-stock]:checked'));
  });
})();

Stock Indicator

The availability of a product on its page: the stock level of the chosen size, a back-in-stock form for sold-out sizes and the pickup availability in stores.

Anatomy

  • .stock-indicator__sizes: a fieldset of size radio buttons, each with its stock in data-stock; a sold-out size is crossed out.
  • .stock-indicator__status: the __level bars and the stock text, with the --low and --out modifiers.
  • .stock-indicator__notify: an email form, shown for a sold-out size.
  • .stock-indicator__stores: the stores with a colored __dot and a status text.

Accessibility and Usability

  • The sizes are native radio buttons in a fieldset with a legend, so they work with the arrow keys and are announced as "Size, M, 2 of 4".
  • A sold-out size stays selectable: the customer can choose it to ask for a notification, instead of meeting a disabled control without an explanation.
  • The status is a polite live region, so changing the size announces the new stock ("Only 3 left, Size M: order soon").
  • The bars and the dots are decorative; the stock and the store status are always written out.

Technical Details

  • The script reads data-stock from the chosen size, and shows "low stock" from 5 items (low in the script). Render the same state on the server, so the first view is right without JavaScript.
  • Real urgency only: show the low stock count when it's true, as a help to decide, not as pressure.
  • Combine it with the Product Details and the Size Guide.

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.