Wishlist

Wishlist (4 items)

  • Linen Overshirt

    Sand · M

    Sale price: $79 Original price: $98

    In stock

  • Ceramic Pour-Over

    Matte white

    $42

    Only 2 left

  • Wireless Headphones

    Graphite

    $199

    Out of stock

  • Leather Weekender

    Cognac

    $249

    In stock

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

.wishlist {
  @include layout-stack('m');

  &__header {
    align-items: center;
    display: flex;
    flex-wrap: wrap;
    gap: spacer('s');
    justify-content: space-between;
  }

  &__title {
    font-size: font-size('h4');
  }

  &__count {
    color: color('muted');
    font-family: config('font-family-base', $typography);
    font-size: config('font-size-base', $typography);
    font-weight: 400;
  }

  &__items {
    display: grid;
    gap: spacer('m');
    grid-template-columns: repeat(auto-fill, minmax(min(13rem, 100%), 1fr));
    list-style: none;
    padding: 0;

    // The items sit side by side: clear the stack margins of the base list style.
    > li {
      margin-block: 0;
      padding-block: 0;
    }
  }

  &__item {
    display: flex;
  }

  &__card {
    background-color: color('surface');
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('lg');
    display: flex;
    flex-direction: column;
    inline-size: 100%;
    overflow: hidden;
  }

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

  &__body {
    flex-grow: 1;
    padding: spacer('s') spacer('s') 0;
    @include layout-stack('xxs');
  }

  &__name {
    font-family: inherit;
    font-size: config('font-size-base', $typography);
    font-weight: 600;

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

      &:hover {
        color: color('primary');
        text-decoration: underline;
      }
    }
  }

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

  &__price {
    color: color('heading');
    font-weight: 700;

    del {
      color: color('muted');
      font-size: config('font-size-sm', $typography);
      font-weight: 400;
    }
  }

  &__stock {
    align-items: center;
    display: flex;
    font-size: config('font-size-sm', $typography);
    gap: spacer('xxs');

    &::before {
      background-color: currentcolor;
      block-size: 0.5rem;
      border-radius: radius('pill');
      content: '';
      inline-size: 0.5rem;
    }

    &--in {
      color: color('success', 'alert');
    }

    &--low {
      color: color('warning', 'alert');
    }

    &--out {
      color: color('muted');
    }
  }

  &__actions {
    display: flex;
    gap: spacer('xs');
    padding: spacer('s');

    .btn:first-child {
      flex-grow: 1;
    }
  }

  &__empty {
    border: config('border-width-base', $display) dashed color('border');
    border-radius: radius('lg');
    padding: spacer('l') spacer('m');
    text-align: center;
    @include layout-stack('s');

    &[hidden] {
      display: none;
    }
  }
}
<section class="wishlist" aria-labelledby="wishlist-title" data-wishlist>
    <header class="wishlist__header">
        <h2 class="wishlist__title" id="wishlist-title">Wishlist <span class="wishlist__count" data-wishlist-count>(4 items)</span></h2>
        <button class="btn btn--secondary btn--sm" type="button" data-wishlist-share>Share list</button>
    </header>
    <ul class="wishlist__items" role="list" data-wishlist-items>
        <li class="wishlist__item" data-wishlist-item>
            <article class="wishlist__card" aria-labelledby="wishlist-item-1">
                <img class="wishlist__image" src="https://images.unsplash.com/photo-1596755094514-f87e34085b2c?auto=format&amp;fit=crop&amp;w=400&amp;q=70" alt="" width="400" height="400" loading="lazy">
                <div class="wishlist__body">
                    <h3 class="wishlist__name" id="wishlist-item-1"><a href="#">Linen Overshirt</a></h3>
                    <p class="wishlist__variant">Sand · M</p>
                    <p class="wishlist__price"><span class="sr-only">Sale price: </span>$79 <del><span class="sr-only">Original price: </span>$98</del></p>
                    <p class="wishlist__stock wishlist__stock--in">In stock</p>
                </div>
                <div class="wishlist__actions">
                    <button class="btn btn--primary btn--sm" type="button" data-wishlist-move>Move to cart<span class="sr-only">: Linen Overshirt</span></button>
                    <button class="btn btn--ghost-danger btn--icon btn--sm" type="button" data-wishlist-remove><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"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg><span class="sr-only">Remove Linen Overshirt from the wishlist</span></button>
                </div>
            </article>
        </li>
        <li class="wishlist__item" data-wishlist-item>
            <article class="wishlist__card" aria-labelledby="wishlist-item-2">
                <img class="wishlist__image" src="https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&amp;fit=crop&amp;w=400&amp;q=70" alt="" width="400" height="400" loading="lazy">
                <div class="wishlist__body">
                    <h3 class="wishlist__name" id="wishlist-item-2"><a href="#">Ceramic Pour-Over</a></h3>
                    <p class="wishlist__variant">Matte white</p>
                    <p class="wishlist__price">$42</p>
                    <p class="wishlist__stock wishlist__stock--low">Only 2 left</p>
                </div>
                <div class="wishlist__actions">
                    <button class="btn btn--primary btn--sm" type="button" data-wishlist-move>Move to cart<span class="sr-only">: Ceramic Pour-Over</span></button>
                    <button class="btn btn--ghost-danger btn--icon btn--sm" type="button" data-wishlist-remove><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"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg><span class="sr-only">Remove Ceramic Pour-Over from the wishlist</span></button>
                </div>
            </article>
        </li>
        <li class="wishlist__item" data-wishlist-item>
            <article class="wishlist__card" aria-labelledby="wishlist-item-3">
                <img class="wishlist__image" src="https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&amp;fit=crop&amp;w=400&amp;q=70" alt="" width="400" height="400" loading="lazy">
                <div class="wishlist__body">
                    <h3 class="wishlist__name" id="wishlist-item-3"><a href="#">Wireless Headphones</a></h3>
                    <p class="wishlist__variant">Graphite</p>
                    <p class="wishlist__price">$199</p>
                    <p class="wishlist__stock wishlist__stock--out">Out of stock</p>
                </div>
                <div class="wishlist__actions">
                    <button class="btn btn--outline-primary btn--sm" type="button" aria-pressed="false" data-wishlist-notify>Notify me<span class="sr-only"> when Wireless Headphones is back</span></button>
                    <button class="btn btn--ghost-danger btn--icon btn--sm" type="button" data-wishlist-remove><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"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg><span class="sr-only">Remove Wireless Headphones from the wishlist</span></button>
                </div>
            </article>
        </li>
        <li class="wishlist__item" data-wishlist-item>
            <article class="wishlist__card" aria-labelledby="wishlist-item-4">
                <img class="wishlist__image" src="https://images.unsplash.com/photo-1547949003-9792a18a2601?auto=format&amp;fit=crop&amp;w=400&amp;q=70" alt="" width="400" height="400" loading="lazy">
                <div class="wishlist__body">
                    <h3 class="wishlist__name" id="wishlist-item-4"><a href="#">Leather Weekender</a></h3>
                    <p class="wishlist__variant">Cognac</p>
                    <p class="wishlist__price">$249</p>
                    <p class="wishlist__stock wishlist__stock--in">In stock</p>
                </div>
                <div class="wishlist__actions">
                    <button class="btn btn--primary btn--sm" type="button" data-wishlist-move>Move to cart<span class="sr-only">: Leather Weekender</span></button>
                    <button class="btn btn--ghost-danger btn--icon btn--sm" type="button" data-wishlist-remove><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"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg><span class="sr-only">Remove Leather Weekender from the wishlist</span></button>
                </div>
            </article>
        </li>
    </ul>
    <div class="wishlist__empty" hidden data-wishlist-empty>
        <p>Your wishlist is empty. Tap the heart on any product to save it for later.</p>
        <a class="btn btn--primary btn--sm" href="#">Continue shopping</a>
    </div>
    <p class="sr-only" aria-live="polite" data-wishlist-status></p>
</section>
(() => {
  document.querySelectorAll('[data-wishlist]').forEach((wishlist) => {
    const list = wishlist.querySelector('[data-wishlist-items]');
    const count = wishlist.querySelector('[data-wishlist-count]');
    const empty = wishlist.querySelector('[data-wishlist-empty]');
    const status = wishlist.querySelector('[data-wishlist-status]');
    const share = wishlist.querySelector('[data-wishlist-share]');

    const update = () => {
      const total = list.children.length;

      count.textContent = `(${total} ${total === 1 ? 'item' : 'items'})`;
      empty.hidden = total > 0;
      list.hidden = total === 0;
      share.hidden = total === 0;
    };

    // Remove an item and move the focus to the next item (or the empty state).
    const removeItem = (item, message) => {
      const next = item.nextElementSibling || item.previousElementSibling;

      item.remove();
      update();
      status.textContent = message;
      (next?.querySelector('a') || empty.querySelector('a')).focus();
    };

    list.addEventListener('click', (event) => {
      const item = event.target.closest('[data-wishlist-item]');

      if (!item) return;

      const name = item.querySelector('.wishlist__name').textContent.trim();
      const notify = event.target.closest('[data-wishlist-notify]');

      if (event.target.closest('[data-wishlist-move]')) {
        removeItem(item, `${name} moved to the cart.`);
      } else if (event.target.closest('[data-wishlist-remove]')) {
        removeItem(item, `${name} removed from the wishlist.`);
      } else if (notify) {
        const isOn = notify.getAttribute('aria-pressed') !== 'true';

        notify.setAttribute('aria-pressed', String(isOn));
        notify.firstChild.textContent = isOn ? 'We’ll email you' : 'Notify me';
        status.textContent = isOn ? `We’ll email you when ${name} is back in stock.` : `Back-in-stock email for ${name} turned off.`;
      }
    });

    share.addEventListener('click', async () => {
      const data = { title: 'My wishlist', url: window.location.href };

      if (navigator.share) {
        try {
          await navigator.share(data);
        } catch (error) {}
      } else if (navigator.clipboard) {
        await navigator.clipboard.writeText(data.url);
        status.textContent = 'The wishlist link is copied to the clipboard.';
      }
    });

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

Wishlist

Saved products with their price, stock status and actions: move to the cart, get notified when an item is back in stock, or remove it.

Anatomy

  • .wishlist__header: the title with the item __count and a share button.
  • .wishlist__items: a grid of __cards (an article per product) with the __image, the __name link, the __variant, the __price (with the original price in del on sale), the __stock status and the __actions.
  • .wishlist__empty: the empty state with a link back to the shop.

Accessibility and Usability

  • Every button has hidden text with the product name ("Remove Ceramic Pour-Over from the wishlist"), so a list of identical buttons stays clear.
  • After moving or removing an item, the focus moves to the next item's link (or the empty state's link), because the clicked button is gone; the change is announced in a polite live region.
  • The sale price has hidden "Sale price" and "Original price" labels, because a strikethrough isn't announced.
  • The stock status is written out ("Only 2 left"); the colored dot only repeats it. An out-of-stock item offers a "Notify me" toggle (aria-pressed) instead of an action that can't work.

Technical Details

  • The grid uses auto-fill, so a short list keeps the card size instead of stretching one card across the row.
  • The share button uses the Web Share API where it's available, and copies the link otherwise.
  • Persist the changes with your API; the script only updates the 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.