Recently Viewed

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

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

  &__header {
    align-items: center;
    display: flex;
    flex-wrap: wrap;
    gap: spacer('xs') spacer('s');
  }

  &__title {
    font-size: font-size('h4');
    margin-inline-end: auto;
  }

  &__controls {
    display: flex;
    gap: spacer('xxs');

    &[hidden] {
      display: none;
    }
  }

  &__track {
    display: grid;
    gap: spacer('m');
    grid-auto-columns: minmax(10rem, calc((100% - #{spacer('m')} * 4) / 5));
    grid-auto-flow: column;
    list-style: none;
    overflow-x: auto;
    overscroll-behavior-inline: contain;
    padding: 0 0 spacer('s');
    scroll-padding-inline: 0;
    scroll-snap-type: inline mandatory;
    scrollbar-width: thin;

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

    @include motion-safe {
      scroll-behavior: smooth;
    }

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

  &__item {
    scroll-snap-align: start;
  }

  &__link {
    color: color('heading');
    display: flex;
    flex-direction: column;
    gap: spacer('xxs');
    text-decoration: none;

    img {
      aspect-ratio: 1;
      background-color: color('surface-muted');
      border-radius: radius('md');
      inline-size: 100%;
      margin-block-end: spacer('xxs');
      object-fit: cover;
      @include transition($property: 'opacity');
    }

    &:hover {
      img {
        opacity: 0.85;
      }

      .recently-viewed__name {
        text-decoration: underline;
      }
    }
  }

  &__name {
    font-size: config('font-size-sm', $typography);
    font-weight: 600;
  }

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

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

[dir='rtl'] .recently-viewed__arrow svg {
  scale: -1 1;
}
<section class="recently-viewed" aria-labelledby="recently-viewed-title" data-recently-viewed>
    <header class="recently-viewed__header">
        <h2 class="recently-viewed__title" id="recently-viewed-title">Recently viewed</h2>
        <div class="recently-viewed__controls" hidden data-recently-viewed-controls>
            <button class="btn btn--secondary btn--icon btn--sm recently-viewed__arrow" type="button" data-direction="-1"><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="m15 18-6-6 6-6"/></svg><span class="sr-only">Scroll back</span></button>
            <button class="btn btn--secondary btn--icon btn--sm recently-viewed__arrow" type="button" data-direction="1"><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="m9 18 6-6-6-6"/></svg><span class="sr-only">Scroll forward</span></button>
        </div>
        <button class="btn btn--ghost-primary btn--sm" type="button" data-recently-viewed-clear>Clear history</button>
    </header>
    <ul class="recently-viewed__track" role="list" tabindex="0" aria-labelledby="recently-viewed-title" data-recently-viewed-track>
        <li class="recently-viewed__item">
            <a class="recently-viewed__link" href="#">
                <img src="https://images.unsplash.com/photo-1553062407-98eeb64c6a62?auto=format&amp;fit=crop&amp;w=400&amp;q=70" alt="" width="400" height="400" loading="lazy">
                <span class="recently-viewed__name">Everyday Backpack</span>
                <span class="recently-viewed__price">$89</span>
            </a>
        </li>
        <li class="recently-viewed__item">
            <a class="recently-viewed__link" href="#">
                <img 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">
                <span class="recently-viewed__name">Ceramic Pour-Over</span>
                <span class="recently-viewed__price">$42</span>
            </a>
        </li>
        <li class="recently-viewed__item">
            <a class="recently-viewed__link" href="#">
                <img 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">
                <span class="recently-viewed__name">Wireless Headphones</span>
                <span class="recently-viewed__price">$199</span>
            </a>
        </li>
        <li class="recently-viewed__item">
            <a class="recently-viewed__link" href="#">
                <img src="https://images.unsplash.com/photo-1507473885765-e6ed057f782c?auto=format&amp;fit=crop&amp;w=400&amp;q=70" alt="" width="400" height="400" loading="lazy">
                <span class="recently-viewed__name">Desk Lamp</span>
                <span class="recently-viewed__price">$79</span>
            </a>
        </li>
        <li class="recently-viewed__item">
            <a class="recently-viewed__link" href="#">
                <img src="https://images.unsplash.com/photo-1523275335684-37898b6baf30?auto=format&amp;fit=crop&amp;w=400&amp;q=70" alt="" width="400" height="400" loading="lazy">
                <span class="recently-viewed__name">Smart Watch</span>
                <span class="recently-viewed__price">$299</span>
            </a>
        </li>
        <li class="recently-viewed__item">
            <a class="recently-viewed__link" href="#">
                <img 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">
                <span class="recently-viewed__name">Leather Weekender</span>
                <span class="recently-viewed__price">$249</span>
            </a>
        </li>
        <li class="recently-viewed__item">
            <a class="recently-viewed__link" href="#">
                <img src="https://images.unsplash.com/photo-1572635196237-14b3f281503f?auto=format&amp;fit=crop&amp;w=400&amp;q=70" alt="" width="400" height="400" loading="lazy">
                <span class="recently-viewed__name">Sunglasses</span>
                <span class="recently-viewed__price">$129</span>
            </a>
        </li>
    </ul>
    <p class="recently-viewed__cleared" tabindex="-1" hidden data-recently-viewed-cleared>Your browsing history is cleared.</p>
</section>
(() => {
  document.querySelectorAll('[data-recently-viewed]').forEach((section) => {
    const track = section.querySelector('[data-recently-viewed-track]');
    const controls = section.querySelector('[data-recently-viewed-controls]');
    const [back, forward] = controls.querySelectorAll('[data-direction]');

    // Disable the arrow at the start or the end of the track (scrollLeft is negative in RTL).
    const update = () => {
      const position = Math.abs(track.scrollLeft);
      const max = track.scrollWidth - track.clientWidth;

      controls.hidden = max <= 1;
      back.disabled = position <= 1;
      forward.disabled = position >= max - 1;
    };

    controls.addEventListener('click', (event) => {
      const button = event.target.closest('[data-direction]');

      if (!button) return;

      const isRtl = getComputedStyle(track).direction === 'rtl';

      // Scroll by most of the visible width, so one item stays as an anchor.
      track.scrollBy({ left: track.clientWidth * 0.8 * button.dataset.direction * (isRtl ? -1 : 1) });
    });

    section.querySelector('[data-recently-viewed-clear]').addEventListener('click', (event) => {
      const cleared = section.querySelector('[data-recently-viewed-cleared]');

      // In a real shop, also clear the stored history.
      track.remove();
      controls.remove();
      event.currentTarget.remove();
      cleared.hidden = false;
      cleared.focus();
    });

    track.addEventListener('scroll', update, { passive: true });
    new ResizeObserver(update).observe(track);
  });
})();

Recently Viewed

A horizontally scrolling row of the products the customer has looked at, with arrow buttons and a "Clear history" action.

Anatomy

  • .recently-viewed__header: the title, the arrow __controls and the clear button.
  • .recently-viewed__track: a list laid out as one scrolling row (a grid with grid-auto-flow: column and scroll snapping); every __item is one link with the image, the __name and the __price.

Accessibility and Usability

  • The track is a native scroll container: it works with touch, the trackpad, the scrollbar and the keyboard. It's focusable (tabindex="0") and labeled by the heading, so keyboard users can scroll it with the arrow keys, and the links inside are reachable with Tab anyway.
  • The arrow buttons are an extra for mouse users. They're disabled at the start and the end, and hidden when everything fits (and without JavaScript).
  • One link per product (the image is decorative, alt=""), so each product is announced once.
  • The smooth scrolling is only on when the user doesn't prefer reduced motion.
  • "Clear history" replaces the row with a message and moves the focus to it, so the focus isn't lost with the removed button.

Technical Details

  • The column width shows five items on a wide screen, with a 10rem minimum; a partly visible item at the edge hints that there's more to scroll.
  • The arrows scroll by 80% of the visible width, so one item stays visible as an anchor. In RTL, scrollLeft is negative, so the script uses its absolute value and flips the direction.
  • A ResizeObserver updates the arrows when the container size changes.
  • Store the history in localStorage or on the account, and render the latest items first.

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.