Order Card

Root SEO extension

License
1 website
Unit price
€100
Quantity of

Line total: €100

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

.order-card {
  align-items: center;
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('lg');
  color: color('text');
  display: grid;
  gap: spacer('s') spacer('m');
  grid-template-columns: auto minmax(0, 1fr);
  padding: spacer-clamp('s', 'm');

  @include breakpoint('sm') {
    grid-template-columns: auto minmax(0, 1fr) auto;
  }

  + .order-card {
    margin-block-start: spacer('s');
  }

  &__thumb {
    align-items: center;
    background-color: color-alpha('primary', 0.1);
    block-size: 3.5rem;
    border-radius: radius('md');
    color: color('primary');
    display: flex;
    inline-size: 3.5rem;
    justify-content: center;

    svg {
      block-size: 1.5rem;
      inline-size: 1.5rem;
    }
  }

  &__data {
    @include layout-stack('xxs');
  }

  &__title {
    font-size: font-size('h5');
    margin-block: 0;
  }

  &__link {
    color: color('heading');
    text-decoration: none;

    &:hover,
    &:focus {
      color: color('primary');
    }
  }

  &__meta {
    color: color('muted');
    display: flex;
    flex-wrap: wrap;
    font-size: config('font-size-sm', $typography);
    gap: spacer('xxs') spacer('s');
    margin-inline: 0;

    div {
      display: flex;
      flex-wrap: wrap;
      gap: spacer('xxs');
      line-height: config('line-height-sm', $typography);
    }

    dt::after {
      content: ':';
    }

    dd {
      color: color('text');
      margin: 0;
    }
  }

  &__actions {
    align-items: center;
    display: flex;
    gap: spacer('s');
    grid-column: 1 / -1;
    justify-content: space-between;

    @include breakpoint('sm') {
      grid-column: auto;
    }
  }

  &__total {
    color: color('heading');
    font-variant-numeric: tabular-nums;
    font-weight: 700;
    margin: 0;
    min-inline-size: 4.5rem;
    text-align: end;
  }

  &.is-removing {
    @include motion-safe {
      opacity: 0;
      transition: opacity 0.2s ease-out;
    }
  }
}
<article class="order-card" data-order-card data-unit-price="50">
    <div class="order-card__thumb" aria-hidden="true">
        <svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 22v-5M9 8V2M15 8V2M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/></svg>
    </div>
    <div class="order-card__data">
        <h3 class="order-card__title" id="order-item-1">
            <a class="order-card__link" href="#">WooCommerce SimplePay integration</a>
        </h3>
        <dl class="order-card__meta">
            <div><dt>License</dt><dd>3 websites</dd></div>
            <div><dt>Unit price</dt><dd>€50</dd></div>
        </dl>
    </div>
    <div class="order-card__actions">
        <div class="number-group" data-number-group>
            <button class="btn btn--primary btn--sm btn--icon number-group__decrement" type="button" aria-controls="order-quantity-1" 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="order-quantity-1" type="number" inputmode="numeric" name="quantity[1]" value="2" min="1" max="20" aria-labelledby="order-quantity-label-1 order-item-1">
            <button class="btn btn--primary btn--sm btn--icon number-group__increment" type="button" aria-controls="order-quantity-1" 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>
        <span class="sr-only" id="order-quantity-label-1">Quantity of</span>
        <p class="order-card__total" aria-live="polite"><span class="sr-only">Line total: </span><span data-order-total>€100</span></p>
        <button class="btn btn--outline-danger btn--sm btn--icon order-card__remove" type="button" aria-describedby="order-item-1" data-order-remove>
            <span class="sr-only">Remove</span>
            <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
        </button>
    </div>
</article>
<article class="order-card" data-order-card data-unit-price="100">
    <div class="order-card__thumb" aria-hidden="true">
        <svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>
    </div>
    <div class="order-card__data">
        <h3 class="order-card__title" id="order-item-2">
            <a class="order-card__link" href="#">Root SEO extension</a>
        </h3>
        <dl class="order-card__meta">
            <div><dt>License</dt><dd>1 website</dd></div>
            <div><dt>Unit price</dt><dd>€100</dd></div>
        </dl>
    </div>
    <div class="order-card__actions">
        <div class="number-group" data-number-group>
            <button class="btn btn--primary btn--sm btn--icon number-group__decrement" type="button" aria-controls="order-quantity-2" 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="order-quantity-2" type="number" inputmode="numeric" name="quantity[2]" value="1" min="1" max="20" aria-labelledby="order-quantity-label-2 order-item-2">
            <button class="btn btn--primary btn--sm btn--icon number-group__increment" type="button" aria-controls="order-quantity-2" 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>
        <span class="sr-only" id="order-quantity-label-2">Quantity of</span>
        <p class="order-card__total" aria-live="polite"><span class="sr-only">Line total: </span><span data-order-total>€100</span></p>
        <button class="btn btn--outline-danger btn--sm btn--icon order-card__remove" type="button" aria-describedby="order-item-2" data-order-remove>
            <span class="sr-only">Remove</span>
            <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
        </button>
    </div>
</article>
<p class="sr-only" aria-live="polite" data-order-status></p>
(() => {
  const status = document.querySelector('[data-order-status]');
  const format = new Intl.NumberFormat(document.documentElement.lang || 'en', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });

  document.querySelectorAll('[data-order-card]').forEach((card) => {
    const input = card.querySelector('input[type="number"]');
    const total = card.querySelector('[data-order-total]');
    const title = card.querySelector('.order-card__title').textContent.trim();

    // The number group dispatches "change" after every step; update the line total.
    input.addEventListener('change', () => {
      total.textContent = format.format(Number(card.dataset.unitPrice) * Number(input.value));
    });

    card.querySelector('[data-order-remove]').addEventListener('click', () => {
      // Move the focus to the next card (or the previous one) before this one is gone.
      const next = card.nextElementSibling?.matches('[data-order-card]') ? card.nextElementSibling : card.previousElementSibling;

      card.classList.add('is-removing');
      setTimeout(() => {
        card.remove();
        next?.querySelector('[data-order-remove]')?.focus();
        status.textContent = `${title} removed from the order.`;
        document.dispatchEvent(new CustomEvent('order:remove', { detail: { title } }));
      }, window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 200);
    });
  });
})();

Order Card

An order line for carts and checkouts: thumbnail, product details, a quantity control, the line total and a remove button.

Anatomy

  • .order-card is an article with the unit price in data-unit-price.
  • .order-card__thumb holds a product image or an icon.
  • .order-card__title, .order-card__meta (a description list for the variant and unit price).
  • .order-card__actions groups the Number Group, the line total (.order-card__total) and the remove button.

Accessibility

  • The quantity input is named by "Quantity of" + the product title (aria-labelledby), so every input in the list is distinguishable.
  • The remove button reads "Remove" and is described by the product title (aria-describedby).
  • After a removal, the focus moves to the next line's remove button, and a live region announces "… removed from the order.". Without this, the focus would be lost on the page.
  • The line total is a polite live region, so its change is announced after a quantity change.

Technical Details

  • Vanilla JavaScript. Load the Number Group script for the quantity controls and card/order.js for the totals and the removal.
  • The prices are formatted with Intl.NumberFormat, from the page language.
  • A removal dispatches an order:remove event on the document, so you can update the cart summary or send the request to your back end.
  • On narrow screens the actions go to their own row; from the sm breakpoint, the card is one row.

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.