Cart Drawer

Your cart (2)

Add $19.00 more for free shipping.

131 of 150

Subtotal $131.00

Taxes calculated at checkout.

Checkout

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

// Built on the Drawer component: load its stylesheet first.
.cart-drawer {
  --drawer-inline-size: 26rem;

  &__count {
    color: color('muted');
    font-weight: 400;
  }

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

  &__shipping-text {
    font-size: config('font-size-sm', $typography);
  }

  &__progress {
    appearance: none;
    background-color: color('border');
    block-size: 0.5rem;
    border: 0;
    border-radius: radius('pill');
    display: block;
    inline-size: 100%;
    overflow: hidden;

    &::-webkit-progress-bar {
      background-color: color('border');
    }

    &::-webkit-progress-value {
      background-color: color('success', 'alert');
      border-radius: radius('pill');
    }

    &::-moz-progress-bar {
      background-color: color('success', 'alert');
      border-radius: radius('pill');
    }
  }

  &__items {
    list-style: none;
    padding: 0;
  }

  &__item {
    align-items: start;
    border-block-end: config('border-width-base', $display) solid color('border');
    display: grid;
    gap: spacer('s');
    grid-template-columns: 5rem minmax(0, 1fr) auto;
    padding-block: spacer('s');
  }

  &__thumb {
    aspect-ratio: 1;
    background-color: color('surface-muted');
    border-radius: radius('md');
    inline-size: 100%;
    object-fit: cover;
  }

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

  &__name {
    color: color('heading');
    display: block;
    font-weight: 700;
    text-decoration: none;

    &:hover {
      text-decoration: underline;
    }
  }

  &__meta {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
  }

  &__quantity {
    align-items: center;
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('pill');
    display: inline-flex;
  }

  &__step {
    align-items: center;
    background: none;
    block-size: 2rem;
    border: 0;
    border-radius: 50%;
    color: color('heading');
    cursor: pointer;
    display: flex;
    font-size: 1.1rem;
    inline-size: 2rem;
    justify-content: center;

    &:hover {
      background-color: color('surface-muted');
    }

    &:focus-visible {
      outline: 2px solid color('focus-ring');
      outline-offset: 1px;
    }
  }

  &__value {
    font-variant-numeric: tabular-nums;
    font-weight: 700;
    min-inline-size: 2ch;
    text-align: center;
  }

  &__price {
    color: color('heading');
    font-variant-numeric: tabular-nums;
    font-weight: 700;
    margin-block: 0;
  }

  &__empty {
    color: color('muted');
    padding-block: spacer('l');
    text-align: center;
  }

  &__footer {
    border-block-start: config('border-width-base', $display) solid color('border');
    display: flex;
    flex-direction: column;
    gap: spacer('xs');
    padding: spacer('m');
  }

  &__subtotal {
    color: color('heading');
    display: flex;
    font-size: font-size('h5');
    justify-content: space-between;
    margin-block: 0;
  }

  &__note {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
    margin-block: 0 spacer('xs');
  }
}
<button class="btn btn--primary" type="button" data-drawer-open="cart-drawer" aria-haspopup="dialog">Open the cart</button>
<dialog class="drawer cart-drawer" id="cart-drawer" aria-labelledby="cart-drawer-title" data-cart-drawer data-free-shipping="150">
    <div class="drawer__panel">
        <header class="drawer__header">
            <h2 class="drawer__title" id="cart-drawer-title">Your cart <span class="cart-drawer__count">(<span data-cart-drawer-count>2</span>)</span></h2>
            <button class="btn btn--ghost-secondary btn--icon btn--sm" type="button" data-drawer-close>
                <svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg>
                <span class="sr-only">Close the cart</span>
            </button>
        </header>
        <div class="drawer__body cart-drawer__body">
            <div class="cart-drawer__shipping">
                <p class="cart-drawer__shipping-text" data-cart-drawer-shipping>Add <strong>$19.00</strong> more for free shipping.</p>
                <progress class="cart-drawer__progress" max="150" value="131" data-cart-drawer-progress>131 of 150</progress>
            </div>
            <ul class="cart-drawer__items" role="list">
                <li class="cart-drawer__item" data-cart-drawer-item data-price="89">
                    <img class="cart-drawer__thumb" src="https://images.unsplash.com/photo-1553062407-98eeb64c6a62?auto=format&amp;fit=crop&amp;w=160&amp;h=160&amp;q=60" alt="" width="80" height="80" loading="lazy">
                    <div class="cart-drawer__info">
                        <a class="cart-drawer__name" href="#">Everyday Backpack</a>
                        <p class="cart-drawer__meta">Charcoal · 22 L</p>
                        <div class="cart-drawer__quantity" role="group" aria-label="Quantity of Everyday Backpack">
                            <button class="cart-drawer__step" type="button" data-cart-drawer-step="-1"><span aria-hidden="true">−</span><span class="sr-only">Decrease</span></button>
                            <output class="cart-drawer__value" data-cart-drawer-quantity>1</output>
                            <button class="cart-drawer__step" type="button" data-cart-drawer-step="1"><span aria-hidden="true">+</span><span class="sr-only">Increase</span></button>
                        </div>
                    </div>
                    <p class="cart-drawer__price" data-cart-drawer-line>$89.00</p>
                </li>
                <li class="cart-drawer__item" data-cart-drawer-item data-price="42">
                    <img class="cart-drawer__thumb" src="https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&amp;fit=crop&amp;w=160&amp;h=160&amp;q=60" alt="" width="80" height="80" loading="lazy">
                    <div class="cart-drawer__info">
                        <a class="cart-drawer__name" href="#">Ceramic Pour-Over</a>
                        <p class="cart-drawer__meta">White</p>
                        <div class="cart-drawer__quantity" role="group" aria-label="Quantity of Ceramic Pour-Over">
                            <button class="cart-drawer__step" type="button" data-cart-drawer-step="-1"><span aria-hidden="true">−</span><span class="sr-only">Decrease</span></button>
                            <output class="cart-drawer__value" data-cart-drawer-quantity>1</output>
                            <button class="cart-drawer__step" type="button" data-cart-drawer-step="1"><span aria-hidden="true">+</span><span class="sr-only">Increase</span></button>
                        </div>
                    </div>
                    <p class="cart-drawer__price" data-cart-drawer-line>$42.00</p>
                </li>
            </ul>
            <p class="cart-drawer__empty" data-cart-drawer-empty hidden>Your cart is empty.</p>
        </div>
        <footer class="cart-drawer__footer">
            <p class="cart-drawer__subtotal"><span>Subtotal</span> <strong data-cart-drawer-subtotal>$131.00</strong></p>
            <p class="cart-drawer__note">Taxes calculated at checkout.</p>
            <a class="btn btn--primary btn--block" href="#">Checkout</a>
            <button class="btn btn--ghost-primary btn--block" type="button" data-drawer-close>Continue shopping</button>
        </footer>
        <p class="sr-only" aria-live="polite" data-cart-drawer-live></p>
    </div>
</dialog>
(() => {
  const money = (value) => `$${value.toFixed(2)}`;

  document.querySelectorAll('[data-cart-drawer]').forEach((drawer) => {
    const freeShipping = Number(drawer.dataset.freeShipping);
    const subtotalOutput = drawer.querySelector('[data-cart-drawer-subtotal]');
    const countOutput = drawer.querySelector('[data-cart-drawer-count]');
    const shippingText = drawer.querySelector('[data-cart-drawer-shipping]');
    const progress = drawer.querySelector('[data-cart-drawer-progress]');
    const empty = drawer.querySelector('[data-cart-drawer-empty]');
    const live = drawer.querySelector('[data-cart-drawer-live]');

    const update = () => {
      const items = [...drawer.querySelectorAll('[data-cart-drawer-item]')];
      let subtotal = 0;
      let count = 0;

      items.forEach((item) => {
        const quantity = Number(item.querySelector('[data-cart-drawer-quantity]').value);
        const line = Number(item.dataset.price) * quantity;

        item.querySelector('[data-cart-drawer-line]').textContent = money(line);
        subtotal += line;
        count += quantity;
      });

      subtotalOutput.textContent = money(subtotal);
      countOutput.textContent = count;
      empty.hidden = items.length > 0;
      progress.value = Math.min(subtotal, freeShipping);
      progress.textContent = `${Math.min(subtotal, freeShipping)} of ${freeShipping}`;
      shippingText.innerHTML = subtotal >= freeShipping
        ? 'You get <strong>free shipping</strong>.'
        : `Add <strong>${money(freeShipping - subtotal)}</strong> more for free shipping.`;

      return subtotal;
    };

    drawer.addEventListener('click', (event) => {
      const step = event.target.closest('[data-cart-drawer-step]');

      if (!step) {
        return;
      }

      const item = step.closest('[data-cart-drawer-item]');
      const output = item.querySelector('[data-cart-drawer-quantity]');
      const name = item.querySelector('.cart-drawer__name').textContent;
      const quantity = Number(output.value) + Number(step.dataset.cartDrawerStep);

      if (quantity < 1) {
        // Removing the item: move the focus to the next sensible place before the button disappears.
        const next = item.nextElementSibling || item.previousElementSibling;

        item.remove();
        (next?.querySelector('[data-cart-drawer-step]') || drawer.querySelector('[data-drawer-close]')).focus();
        update();
        live.textContent = `${name} removed from the cart.`;

        return;
      }

      output.value = quantity;
      live.textContent = `${name}: quantity ${quantity}. Subtotal ${money(update())}.`;
    });

    update();
  });
})();

Cart Drawer

On this page

A slide-in cart: the items with quantity steppers, the progress to free shipping, the subtotal and the way to checkout, without leaving the page.

Technical Details

  • It's built on the Drawer (a native dialog opened with showModal()): load the drawer stylesheet and script too. Any data-drawer-open="cart-drawer" element opens it, like the cart link of the store header; a link keeps working as a link to the cart page without JavaScript.
  • Every quantity stepper is a labelled group ("Quantity of Everyday Backpack") with hidden "Decrease" and "Increase" labels; the value is an output.
  • A polite live region announces the new quantity and subtotal, or that an item was removed. When an item is removed, the focus moves to the next item (or to the close button), never to the document body.
  • The free shipping progress is a native progress element with a text fallback; the text above it says how much is missing.
  • Override the items with cartDrawer ({ freeShipping, items: [{ name, meta, price, quantity, image }] }); set cartDrawerNoTrigger to leave out the demo button on a page.

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.