Quick View

Wireless Headphones in black, side view.

Wireless Headphones

Rated 4.8 out of 5, 412 reviews

$199.00

Active noise cancelling, 40 hours of battery life and a foldable design that fits in every bag.

Color

View full details
@use 'sprucecss/scss/spruce' as *;

.quick-view-trigger {
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('lg');
  inline-size: 15rem;
  padding: spacer('s');
  @include layout-stack('xs');

  img {
    aspect-ratio: 1;
    border-radius: radius('md');
    inline-size: 100%;
    object-fit: cover;
  }

  &__title {
    font-family: inherit;
    font-size: config('font-size-base', $typography);
  }
}

.quick-view {
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('lg');
  box-shadow: shadow('xl');
  inline-size: min(56rem, calc(100vi - #{spacer('m')} * 2));
  max-block-size: calc(100dvb - #{spacer('m')} * 2);
  max-inline-size: none;
  overflow-y: auto;
  padding: 0;

  &::backdrop {
    background-color: color('overlay');
  }

  @include motion-safe {
    &[open] {
      animation: quick-view-in 0.2s ease-out;
    }
  }

  &__inner {
    display: grid;
    position: relative;

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

  &__close {
    background-color: color-alpha('surface', 0.85) !important;
    inset-block-start: spacer('xs');
    inset-inline-end: spacer('xs');
    position: absolute;
    z-index: 1;
  }

  &__media {
    aspect-ratio: 4 / 3;
    background-color: color('surface-muted');
    block-size: 100%;
    inline-size: 100%;
    object-fit: cover;

    @include breakpoint('sm') {
      aspect-ratio: auto;
    }
  }

  &__body {
    padding: spacer-clamp('m', 'l');
    @include layout-stack('s');
  }

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

  &__title {
    font-size: font-size('h3');
    margin: 0;
    padding-inline-end: spacer('l');
  }

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

    span[aria-hidden] {
      color: color('warning', 'alert');
      letter-spacing: 0.1em;
    }
  }

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

  &__option {
    border: 0;
    margin-inline: 0;
    padding: 0;
  }

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

  &__swatches {
    display: flex;
    gap: spacer('xs');
  }

  &__swatch {
    aspect-ratio: 1;
    background-color: var(--swatch);
    border: 2px solid color('surface');
    border-radius: radius('pill');
    box-shadow: 0 0 0 1px color('border');
    cursor: pointer;
    inline-size: 2rem;
    position: relative;

    input {
      appearance: none;
      border-radius: inherit;
      cursor: pointer;
      inset: 0;
      margin: 0;
      // Invisible but focusable and clickable; the label shows the state.
      opacity: 0;
      position: absolute;
    }

    &:has(:checked) {
      box-shadow: 0 0 0 2px color('heading');
    }

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

  &__buy {
    display: grid;
    gap: spacer('xs');
    grid-template-columns: 5rem 1fr;
  }

  &__status {
    color: color('heading');
    font-size: config('font-size-sm', $typography);

    &:not(:empty) {
      background-color: color-alpha('success', 0.12, 'alert');
      border-radius: radius('md');
      padding: spacer('xs') spacer('s');
    }
  }

  &__link {
    display: inline-block;
    font-size: config('font-size-sm', $typography);
    font-weight: 600;
  }
}

@keyframes quick-view-in {
  from {
    opacity: 0;
    scale: 0.96;
  }
}
<article class="quick-view-trigger">
    <img src="https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&fit=crop&w=480&h=480&q=60" alt="" width="240" height="240" loading="lazy">
    <h3 class="quick-view-trigger__title">Wireless Headphones</h3>
    <a class="btn btn--outline-primary btn--sm" href="#" data-quick-view-open="quick-view-headphones">Quick view<span class="sr-only">: Wireless Headphones</span></a>
</article>
<dialog class="quick-view" id="quick-view-headphones" aria-labelledby="quick-view-title">
    <div class="quick-view__inner">
        <button class="btn btn--ghost-secondary btn--icon btn--sm quick-view__close" type="button" data-quick-view-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</span>
        </button>
        <img class="quick-view__media" src="https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&fit=crop&w=800&h=800&q=70" alt="Wireless Headphones in black, side view." width="800" height="800" loading="lazy">
        <form class="quick-view__body" action="#" method="post" data-quick-view-form>
            <div class="quick-view__heading">
                <h2 class="quick-view__title" id="quick-view-title">Wireless Headphones</h2>
                <p class="quick-view__rating"><span aria-hidden="true">★★★★★</span> <span class="sr-only">Rated 4.8 out of 5,</span> 412 reviews</p>
            </div>
            <p class="quick-view__price">$199.00</p>
            <p class="quick-view__text">Active noise cancelling, 40 hours of battery life and a foldable design that fits in every bag.</p>
            <fieldset class="quick-view__option">
                <legend class="quick-view__legend">Color</legend>
                <div class="quick-view__swatches">
                    <label class="quick-view__swatch" style="--swatch: hsl(0deg 0% 12%);"><input type="radio" name="color" value="black" checked><span class="sr-only">Black</span></label>
                    <label class="quick-view__swatch" style="--swatch: hsl(35deg 25% 85%);"><input type="radio" name="color" value="sand"><span class="sr-only">Sand</span></label>
                    <label class="quick-view__swatch" style="--swatch: hsl(215deg 35% 35%);"><input type="radio" name="color" value="navy"><span class="sr-only">Navy</span></label>
                </div>
            </fieldset>
            <div class="quick-view__buy">
                <label class="sr-only" for="quick-view-quantity">Quantity</label>
                <input class="form-control" id="quick-view-quantity" name="quantity" type="number" min="1" value="1" inputmode="numeric">
                <button class="btn btn--primary" type="submit" data-quick-view-add>Add to cart</button>
            </div>
            <p class="quick-view__status" role="status" data-quick-view-status></p>
            <a class="quick-view__link" href="#">View full details</a>
        </form>
    </div>
</dialog>
(() => {
  document.querySelectorAll('[data-quick-view-open]').forEach((opener) => {
    const dialog = document.getElementById(opener.dataset.quickViewOpen);

    if (!dialog) {
      return;
    }

    const form = dialog.querySelector('[data-quick-view-form]');
    const status = dialog.querySelector('[data-quick-view-status]');

    // Without JavaScript, the opener is a link to the product page.
    opener.setAttribute('role', 'button');
    opener.setAttribute('aria-haspopup', 'dialog');

    const open = (event) => {
      event.preventDefault();
      status.textContent = '';
      dialog.showModal();
    };

    opener.addEventListener('click', open);
    opener.addEventListener('keydown', (event) => {
      if (event.key === ' ') {
        open(event);
      }
    });

    dialog.addEventListener('click', (event) => {
      // A click on the backdrop targets the dialog itself.
      if (event.target === dialog || event.target.closest('[data-quick-view-close]')) {
        dialog.close();
      }
    });

    // The native dialog returns the focus to the opener when it closes.
    form.addEventListener('submit', (event) => {
      event.preventDefault();
      form.dispatchEvent(new CustomEvent('cart:add', { bubbles: true, detail: { data: new FormData(form) } }));
      status.innerHTML = '';
      status.append(`${form.elements.quantity.value} × ${dialog.querySelector('.quick-view__title').textContent} added to your cart. `);

      const link = document.createElement('a');
      link.href = '#';
      link.textContent = 'View cart';
      status.append(link);
    });
  });
})();

Quick View

A product preview in a modal dialog: photo, rating, price, options and "Add to cart", without leaving the listing.

Anatomy

  • [data-quick-view-open]: the opener, a link to the product page with the id of the dialog.
  • .quick-view: a native dialog labelled by the product title. Inside: the __close button, the __media and the __body form with the options, the quantity and the submit button.
  • .quick-view__status: the confirmation after adding to the cart.

Accessibility and Usability

  • It's a native dialog opened with showModal(): the browser traps the focus inside, makes the rest of the page inert, closes it with Escape and returns the focus to the opener.
  • Progressive enhancement: the opener is a link to the product page. With JavaScript, it becomes a button (role="button", aria-haspopup="dialog", space key support) that opens the dialog.
  • The opener has hidden context ("Quick view: Wireless Headphones"), because a listing has many of them.
  • The confirmation is a role="status" message with a link to the cart, so the result of the action is clear and announced; the dialog stays open so people can keep shopping.
  • Always link to the full product page; a quick view is a shortcut, not a replacement.

Technical Details

  • A click on the backdrop closes the dialog (the click targets the dialog element itself).
  • On submit, the script dispatches a bubbling cart:add event with the FormData; add the item with a request in a listener and update the Mini Cart.
  • Load the product into the dialog on demand (for example, fetch the HTML of the dialog when the opener is clicked) if a listing has many products.
  • Two columns from the sm breakpoint; on phones, the image is on top, and the dialog scrolls.

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.