Mini Cart

Cart 4 items
@use 'sprucecss/scss/spruce' as *;

.mini-cart {
  display: inline-block;
  position: relative;

  &__count {
    background-color: color('primary');
    border-radius: radius('pill');
    color: color('primary-foreground', 'btn');
    font-size: 0.75rem;
    font-weight: 700;
    line-height: 1;
    min-inline-size: 1.5em;
    padding: 0.25em 0.5em;
    text-align: center;
  }

  &__panel {
    background-color: color('surface');
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('lg');
    box-shadow: shadow('xl');
    inline-size: min(24rem, calc(100vi - #{spacer('m')} * 2));
    inset-block-start: calc(100% + #{spacer('xs')});
    inset-inline-end: 0;
    position: absolute;
    z-index: z-index('dropdown');

    &:focus {
      outline: none;
    }

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

    @include motion-safe {
      animation: mini-cart-in 0.2s ease-out;
    }
  }

  &__header {
    align-items: center;
    border-block-end: config('border-width-base', $display) solid color('border');
    display: flex;
    justify-content: space-between;
    padding: spacer('xs') spacer('xs') spacer('xs') spacer('m');
  }

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

  &__items {
    @include clear-list;
    max-block-size: 18rem;
    overflow-y: auto;
    padding-inline: spacer('m');
  }

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

    &:last-child {
      border-block-end: 0;
    }
  }

  &__thumb {
    aspect-ratio: 1;
    border-radius: radius('md');
    inline-size: 3.5rem;
    object-fit: cover;
  }

  &__info {
    display: grid;
    font-size: config('font-size-sm', $typography);
    line-height: config('line-height-md', $typography);
  }

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

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

  &__meta {
    color: color('muted');
  }

  &__price {
    color: color('heading');
    font-size: config('font-size-sm', $typography);
    font-variant-numeric: tabular-nums;
    font-weight: 700;
  }

  &__remove {
    @include clear-btn;
    border-radius: radius('sm');
    color: color('muted');
    display: inline-flex;
    padding: spacer('xxs');

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

    &:hover {
      color: color('danger', 'alert');
    }

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

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

  &__footer {
    background-color: color('surface-muted');
    border-block-start: config('border-width-base', $display) solid color('border');
    border-end-end-radius: inherit;
    border-end-start-radius: inherit;
    padding: spacer('m');
    @include layout-stack('xs');
  }

  &__subtotal {
    color: color('heading');
    display: flex;
    font-size: config('font-size-lg', $typography);
    justify-content: space-between;
    margin: 0;
  }

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

  &__actions {
    display: grid;
    gap: spacer('xs');
    grid-template-columns: 1fr 1fr;
    padding-block-start: spacer('xs');
  }
}

[dir='rtl'] .mini-cart__header {
  padding: spacer('xs') spacer('m') spacer('xs') spacer('xs');
}

@keyframes mini-cart-in {
  from {
    opacity: 0;
    translate: 0 -0.5rem;
  }
}
<div class="mini-cart" data-mini-cart>
    <a class="btn btn--outline-secondary mini-cart__toggle" href="#" aria-controls="mini-cart-panel" data-mini-cart-toggle>
        <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" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/></svg>
        Cart
        <span class="mini-cart__count"><span data-mini-cart-count>4</span><span class="sr-only"> items</span></span>
    </a>
    <div class="mini-cart__panel" id="mini-cart-panel" role="region" aria-labelledby="mini-cart-title" tabindex="-1" hidden>
        <header class="mini-cart__header">
            <h2 class="mini-cart__title" id="mini-cart-title">Your cart</h2>
            <button class="btn btn--ghost-secondary btn--icon btn--sm" type="button" data-mini-cart-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>
        <ul class="mini-cart__items" role="list" data-mini-cart-items>
            <li class="mini-cart__item" data-mini-cart-item data-price="89" data-quantity="1">
                <img class="mini-cart__thumb" src="https://images.unsplash.com/photo-1553062407-98eeb64c6a62?auto=format&fit=crop&w=128&h=128&q=60" alt="" width="64" height="64" loading="lazy">
                <div class="mini-cart__info">
                    <a class="mini-cart__name" href="#">Everyday Backpack</a>
                    <span class="mini-cart__meta">Charcoal · 22 L · Qty 1</span>
                </div>
                <span class="mini-cart__price">$89.00</span>
                <button class="mini-cart__remove" type="button" data-mini-cart-remove>
                    <svg 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">Remove Everyday Backpack</span>
                </button>
            </li>
            <li class="mini-cart__item" data-mini-cart-item data-price="42" data-quantity="2">
                <img class="mini-cart__thumb" src="https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&w=128&h=128&q=60" alt="" width="64" height="64" loading="lazy">
                <div class="mini-cart__info">
                    <a class="mini-cart__name" href="#">Ceramic Pour-Over</a>
                    <span class="mini-cart__meta">White · Qty 2</span>
                </div>
                <span class="mini-cart__price">$84.00</span>
                <button class="mini-cart__remove" type="button" data-mini-cart-remove>
                    <svg 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">Remove Ceramic Pour-Over</span>
                </button>
            </li>
            <li class="mini-cart__item" data-mini-cart-item data-price="199" data-quantity="1">
                <img class="mini-cart__thumb" src="https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&fit=crop&w=128&h=128&q=60" alt="" width="64" height="64" loading="lazy">
                <div class="mini-cart__info">
                    <a class="mini-cart__name" href="#">Wireless Headphones</a>
                    <span class="mini-cart__meta">Black · Qty 1</span>
                </div>
                <span class="mini-cart__price">$199.00</span>
                <button class="mini-cart__remove" type="button" data-mini-cart-remove>
                    <svg 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">Remove Wireless Headphones</span>
                </button>
            </li>
        </ul>
        <p class="mini-cart__empty" data-mini-cart-empty hidden>Your cart is empty.</p>
        <footer class="mini-cart__footer">
            <p class="mini-cart__subtotal"><span>Subtotal</span> <strong data-mini-cart-subtotal>$372.00</strong></p>
            <p class="mini-cart__note">Shipping and taxes are calculated at checkout.</p>
            <div class="mini-cart__actions">
                <a class="btn btn--outline-primary" href="#">View cart</a>
                <a class="btn btn--primary" href="#" data-mini-cart-checkout>Checkout</a>
            </div>
        </footer>
        <p class="sr-only" aria-live="polite" data-mini-cart-live></p>
    </div>
</div>
(() => {
  document.querySelectorAll('[data-mini-cart]').forEach((cart) => {
    const toggle = cart.querySelector('[data-mini-cart-toggle]');
    const panel = cart.querySelector('#' + toggle.getAttribute('aria-controls'));
    const list = cart.querySelector('[data-mini-cart-items]');
    const live = cart.querySelector('[data-mini-cart-live]');
    const format = new Intl.NumberFormat(document.documentElement.lang || 'en', { style: 'currency', currency: 'USD' });

    // Without JavaScript, the toggle is a link to the cart page.
    toggle.setAttribute('role', 'button');
    toggle.setAttribute('aria-expanded', 'false');

    const setOpen = (open, returnFocus = true) => {
      panel.hidden = !open;
      toggle.setAttribute('aria-expanded', String(open));

      if (open) {
        panel.focus();
      } else if (returnFocus) {
        toggle.focus();
      }
    };

    const update = () => {
      const items = [...list.querySelectorAll('[data-mini-cart-item]')];
      const count = items.reduce((sum, item) => sum + Number(item.dataset.quantity), 0);
      const subtotal = items.reduce((sum, item) => sum + Number(item.dataset.price) * Number(item.dataset.quantity), 0);

      cart.querySelector('[data-mini-cart-count]').textContent = count;
      cart.querySelector('[data-mini-cart-subtotal]').textContent = format.format(subtotal);
      cart.querySelector('[data-mini-cart-empty]').hidden = count > 0;
      cart.querySelector('.mini-cart__footer').hidden = count === 0;
      list.hidden = count === 0;
    };

    toggle.addEventListener('click', (event) => {
      event.preventDefault();
      setOpen(panel.hidden);
    });

    // A space press on a link doesn't click it; it does on a button.
    toggle.addEventListener('keydown', (event) => {
      if (event.key === ' ') {
        event.preventDefault();
        setOpen(panel.hidden);
      }
    });

    cart.querySelector('[data-mini-cart-close]').addEventListener('click', () => setOpen(false));

    cart.addEventListener('keydown', (event) => {
      if (event.key === 'Escape' && !panel.hidden) {
        setOpen(false);
      }
    });

    // Close on a click outside, without stealing the focus from what was clicked.
    document.addEventListener('click', (event) => {
      if (!panel.hidden && !cart.contains(event.target)) {
        setOpen(false, false);
      }
    });

    list.addEventListener('click', (event) => {
      const button = event.target.closest('[data-mini-cart-remove]');

      if (!button) {
        return;
      }

      const item = button.closest('[data-mini-cart-item]');
      const name = item.querySelector('.mini-cart__name').textContent;
      const next = item.nextElementSibling || item.previousElementSibling;

      item.remove();
      update();
      live.textContent = `${name} removed. Subtotal: ${cart.querySelector('[data-mini-cart-subtotal]').textContent}.`;
      cart.dispatchEvent(new CustomEvent('cart:remove', { bubbles: true, detail: { name } }));
      (next?.querySelector('[data-mini-cart-remove]') || panel).focus();
    });

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

Mini Cart

A cart button for the site header with an item count and a dropdown panel listing the cart items, the subtotal and the checkout buttons.

Anatomy

  • .mini-cart__toggle: a link to the cart page with the __count badge. With JavaScript, it opens the panel instead.
  • .mini-cart__panel: a labelled region with the __header, the __items (thumbnail, name, options, price and a remove button), the __empty message and the __footer (subtotal, note and buttons).
  • Every item carries its data-price and data-quantity, so the count and the subtotal can be recalculated.

Accessibility and Usability

  • Progressive enhancement: without JavaScript, the toggle is a plain link to the cart page. The script turns it into a disclosure button (role="button", aria-expanded, aria-controls) that also reacts to the space key.
  • The count has hidden text ("4 items"), so the button reads "Cart, 4 items".
  • When the panel opens, the focus moves into it; Escape and the close button return the focus to the toggle. A click outside closes it without moving the focus.
  • It's not a modal: the rest of the page stays usable, so it uses a region, not role="dialog". For a modal cart, use the Drawer.
  • The remove buttons name the product ("Remove Ceramic Pour-Over"); a removal is announced with the new subtotal, and the focus moves to the next remove button.

Technical Details

  • The script dispatches a bubbling cart:remove event; update the server-side cart in its listener. The Product Details dispatches cart:add: re-render the panel from your cart endpoint when it fires.
  • The panel is positioned under the toggle from the inline end, and it's never wider than the viewport.
  • The item list scrolls after about four items (max-block-size: 18rem), so the checkout button stays visible.

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.