Product Compare

Compare backpacks

Comparison of 3 backpacks; the products are in the columns
Everyday Backpack $89 Add to cart: Everyday Backpack Heritage Backpack $189 Add to cart: Heritage Backpack Commuter Pack $129 Add to cart: Commuter Pack
Rating4.7 / 54.6 / 54.4 / 5
Capacity22 L26 L24 L
Weight0.9 kg1.4 kg1.1 kg
MaterialRecycled canvasWaxed cotton, leatherCoated nylon
Laptop sleeve16″16″16″
Water-resistantNoYesYes
Lifetime repairsYesYesYes
Warranty2 years2 years5 years

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

.product-compare {
  @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');
    margin: 0;
  }

  &__scroll {
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('lg');
    overflow-x: auto;

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

  &__table {
    margin: 0;
    min-inline-size: 40rem;
    // Equal product columns.
    table-layout: fixed;

    th,
    td {
      padding: spacer('s');
      vertical-align: top;
    }

    tbody th {
      color: color('muted');
      font-weight: 600;
      white-space: nowrap;
    }

    tbody tr:nth-child(odd) {
      background-color: color('surface-muted');
    }

    // Keep the row labels visible while scrolling.
    tr > :first-child {
      background-color: color('surface');
      inset-inline-start: 0;
      position: sticky;
      z-index: 1;
    }

    tbody tr:nth-child(odd) > :first-child {
      background-color: color('surface-muted');
    }

    tr[data-same] td {
      color: color('muted');
    }
  }

  &__corner {
    inline-size: 10rem;
  }

  &__product {
    position: relative;
    text-align: start;

    img {
      aspect-ratio: 1;
      border-radius: radius('md');
      inline-size: 7.5rem;
      margin-block-end: spacer('xs');
      object-fit: cover;
    }

    .btn {
      margin-block-start: spacer('xs');
    }
  }

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

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

  &__price {
    color: color('heading');
    display: block;
    font-weight: 400;
  }

  &__remove {
    @include clear-btn;
    background-color: color('surface');
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('pill');
    color: color('muted');
    display: inline-flex;
    inset-block-start: spacer('s');
    inset-inline-end: spacer('s');
    padding: spacer('xxs');
    position: absolute;

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

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

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

  &__icon {
    block-size: 1.25rem;
    color: color('muted');
    inline-size: 1.25rem;

    &--yes {
      color: color('success', 'alert');
    }
  }
}
<section class="product-compare" aria-labelledby="product-compare-title" data-product-compare>
    <header class="product-compare__header">
        <h2 class="product-compare__title" id="product-compare-title">Compare backpacks</h2>
        <label class="form-switch" data-compare-differences hidden>
            <input class="form-switch__control" type="checkbox" role="switch">
            <span class="form-label form-switch__label">Only show differences</span>
        </label>
    </header>
    <div class="product-compare__scroll" role="region" aria-labelledby="product-compare-title" tabindex="0">
        <table class="table product-compare__table">
            <caption class="sr-only">Comparison of 3 backpacks; the products are in the columns</caption>
            <thead>
                <tr>
                    <td class="product-compare__corner"></td>
                    <th scope="col" class="product-compare__product" data-compare-column>
                        <img src="https://images.unsplash.com/photo-1553062407-98eeb64c6a62?auto=format&fit=crop&w=240&h=240&q=60" alt="" width="120" height="120" loading="lazy">
                        <a class="product-compare__name" href="#">Everyday Backpack</a>
                        <span class="product-compare__price">$89</span>
                        <a class="btn btn--primary btn--sm" href="#">Add to cart<span class="sr-only">: Everyday Backpack</span></a>
                        <button class="product-compare__remove" type="button" data-compare-remove hidden>
                            <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 from the comparison</span>
                        </button>
                    </th>
                    <th scope="col" class="product-compare__product" data-compare-column>
                        <img src="https://images.unsplash.com/photo-1577733966973-d680bffd2e80?auto=format&fit=crop&w=240&h=240&q=60" alt="" width="120" height="120" loading="lazy">
                        <a class="product-compare__name" href="#">Heritage Backpack</a>
                        <span class="product-compare__price">$189</span>
                        <a class="btn btn--primary btn--sm" href="#">Add to cart<span class="sr-only">: Heritage Backpack</span></a>
                        <button class="product-compare__remove" type="button" data-compare-remove hidden>
                            <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 Heritage Backpack from the comparison</span>
                        </button>
                    </th>
                    <th scope="col" class="product-compare__product" data-compare-column>
                        <img src="https://images.unsplash.com/photo-1585916420730-d7f95e942d43?auto=format&fit=crop&w=240&h=240&q=60" alt="" width="120" height="120" loading="lazy">
                        <a class="product-compare__name" href="#">Commuter Pack</a>
                        <span class="product-compare__price">$129</span>
                        <a class="btn btn--primary btn--sm" href="#">Add to cart<span class="sr-only">: Commuter Pack</span></a>
                        <button class="product-compare__remove" type="button" data-compare-remove hidden>
                            <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 Commuter Pack from the comparison</span>
                        </button>
                    </th>
                </tr>
            </thead>
            <tbody>
                <tr data-compare-row>
                    <th scope="row">Rating</th>
                    <td>4.7 / 5</td>
                    <td>4.6 / 5</td>
                    <td>4.4 / 5</td>
                </tr>
                <tr data-compare-row>
                    <th scope="row">Capacity</th>
                    <td>22 L</td>
                    <td>26 L</td>
                    <td>24 L</td>
                </tr>
                <tr data-compare-row>
                    <th scope="row">Weight</th>
                    <td>0.9 kg</td>
                    <td>1.4 kg</td>
                    <td>1.1 kg</td>
                </tr>
                <tr data-compare-row>
                    <th scope="row">Material</th>
                    <td>Recycled canvas</td>
                    <td>Waxed cotton, leather</td>
                    <td>Coated nylon</td>
                </tr>
                <tr data-compare-row>
                    <th scope="row">Laptop sleeve</th>
                    <td>16″</td>
                    <td>16″</td>
                    <td>16″</td>
                </tr>
                <tr data-compare-row>
                    <th scope="row">Water-resistant</th>
                    <td><svg class="product-compare__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="M5 12h14"/></svg><span class="sr-only">No</span></td>
                    <td><svg class="product-compare__icon product-compare__icon--yes" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg><span class="sr-only">Yes</span></td>
                    <td><svg class="product-compare__icon product-compare__icon--yes" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg><span class="sr-only">Yes</span></td>
                </tr>
                <tr data-compare-row>
                    <th scope="row">Lifetime repairs</th>
                    <td><svg class="product-compare__icon product-compare__icon--yes" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg><span class="sr-only">Yes</span></td>
                    <td><svg class="product-compare__icon product-compare__icon--yes" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg><span class="sr-only">Yes</span></td>
                    <td><svg class="product-compare__icon product-compare__icon--yes" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg><span class="sr-only">Yes</span></td>
                </tr>
                <tr data-compare-row>
                    <th scope="row">Warranty</th>
                    <td>2 years</td>
                    <td>2 years</td>
                    <td>5 years</td>
                </tr>
            </tbody>
        </table>
    </div>
    <p class="sr-only" aria-live="polite" data-compare-live></p>
</section>
(() => {
  document.querySelectorAll('[data-product-compare]').forEach((compare) => {
    const table = compare.querySelector('table');
    const toggle = compare.querySelector('[data-compare-differences]');
    const checkbox = toggle.querySelector('input');
    const live = compare.querySelector('[data-compare-live]');

    // A row is "the same" when every product has the same value.
    const markRows = () => {
      table.querySelectorAll('[data-compare-row]').forEach((row) => {
        const values = [...row.querySelectorAll('td')].map((cell) => cell.textContent.trim());
        row.toggleAttribute('data-same', new Set(values).size <= 1);
      });
    };

    const filterRows = () => {
      table.querySelectorAll('[data-compare-row]').forEach((row) => {
        row.hidden = checkbox.checked && row.hasAttribute('data-same');
      });
    };

    toggle.hidden = false;
    checkbox.addEventListener('change', () => {
      filterRows();
      const hidden = table.querySelectorAll('[data-compare-row][hidden]').length;
      live.textContent = checkbox.checked ? `${hidden} identical ${hidden === 1 ? 'row' : 'rows'} hidden.` : 'All rows shown.';
    });

    table.querySelectorAll('[data-compare-remove]').forEach((button) => {
      button.hidden = false;
      button.addEventListener('click', () => {
        const header = button.closest('th');
        const index = header.cellIndex;
        const name = header.querySelector('.product-compare__name').textContent;
        const columns = table.querySelectorAll('[data-compare-column]').length;

        if (columns <= 2) {
          live.textContent = 'Keep at least two products to compare.';
          return;
        }

        table.querySelectorAll('tr').forEach((row) => row.cells[index].remove());
        table.querySelector('caption').textContent = `Comparison of ${columns - 1} backpacks; the products are in the columns`;
        markRows();
        filterRows();
        live.textContent = `${name} removed from the comparison.`;
        table.querySelector('[data-compare-remove]').focus();
      });
    });

    markRows();
  });
})();

Product Compare

A side-by-side product comparison table with a sticky label column, removable products and a switch that hides the identical rows.

Anatomy

  • .product-compare__table: a Spruce .table with the products in the columns: the header cells (__product) hold the image, the name, the price, "Add to cart" and the remove button.
  • [data-compare-row]: a feature row with a row header; the script marks the identical rows with data-same.
  • [data-compare-differences]: the "Only show differences" switch.

Accessibility and Usability

  • It's a real table with a caption, column headers (the products) and row headers (the features), so screen readers announce "Heritage Backpack, Capacity, 26 L" for every cell.
  • The check and dash icons have hidden "Yes" and "No" text.
  • The scroll container is a focusable, labelled region, so keyboard users can scroll it; the feature labels stay visible while scrolling (position: sticky).
  • The remove buttons name the product; after a removal, the change is announced and the focus moves to the next remove button. The last two products can't be removed, because there would be nothing to compare.
  • Hiding the identical rows is announced ("2 identical rows hidden"); identical values are also dimmed when they're shown.

Technical Details

  • Progressive enhancement: without JavaScript, the full table is shown; the switch and the remove buttons are revealed by the script. Remove the product from the server-side comparison list too (for example, with a link to ?remove=…).
  • A row is "the same" when the text content of all its cells is equal, so it works with icons that have hidden text.
  • The table has a 40rem minimum width and scrolls horizontally on smaller screens. For more than four products, keep the scrolling and the sticky column.

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.