← Visual Library / Carousels

Gallery hover-preview + click-pin

Product-gallery interaction that combines all three pointer modes: hover peeks the thumbnail into the main slot, mouseleave restores the last-pinned choice, and click sets a new pin. Focus events mirror hover for keyboard a11y. Smoother than click-only, less jumpy than hover-only.

Carousels Built
Why hover + click + focus, not just one.
Use when — product detail pages with a hero image + 4–8 supporting shots (lifestyle, detail crops, packaging, accessories). Especially valuable when the supporting shots are visually rich enough that users want to scrub through them quickly before committing.

Live demo

Hover over a thumbnail to preview it in the main slot. Click to pin. The active pin stays as your default when you mouse away.

HTML

<div class="gallery">
  <div class="gallery-main">
    <img src="assets/main.jpg" alt="Main product shot">
  </div>
  <div class="gallery-thumbs">
    <!-- First thumb = same src as main, so it gets .is-active auto and clicking it returns home -->
    <figure><img src="assets/main.jpg" alt="Main view"></figure>
    <figure><img src="assets/lifestyle.jpg" alt="In use"></figure>
    <figure><img src="assets/detail.jpg" alt="Material detail"></figure>
    <figure><img src="assets/back.jpg" alt="Back view"></figure>
  </div>
</div>

JS — the full pattern

(function() {
  const main = document.querySelector('.gallery-main img');
  if (!main) return;
  const thumbs = document.querySelectorAll('.gallery-thumbs figure');

  // "stickySrc" = the image currently locked in by a click
  let stickySrc = main.getAttribute('src');
  let stickyAlt = main.getAttribute('alt');

  const setMain = (src, alt) => {
    if (main.src !== src) main.src = src;
    if (alt && main.alt !== alt) main.alt = alt;
  };

  thumbs.forEach(fig => {
    const img = fig.querySelector('img');
    if (!img) return;

    // Auto-mark the active thumb
    if (img.getAttribute('src') === stickySrc) fig.classList.add('is-active');

    // HOVER — preview-swap, doesn't change sticky
    fig.addEventListener('mouseenter', () => setMain(img.src, img.alt));
    fig.addEventListener('mouseleave', () => setMain(stickySrc, stickyAlt));

    // FOCUS — same as hover (keyboard a11y, required)
    fig.addEventListener('focusin',  () => setMain(img.src, img.alt));
    fig.addEventListener('focusout', () => setMain(stickySrc, stickyAlt));

    // CLICK — pin a new sticky choice
    fig.addEventListener('click', () => {
      stickySrc = img.src;
      stickyAlt = img.alt;
      setMain(stickySrc, stickyAlt);
      thumbs.forEach(f => f.classList.remove('is-active'));
      fig.classList.add('is-active');
    });
  });
})();

CSS — minimum viable

.gallery-main {
  aspect-ratio: 1;
  display: flex; align-items: center; justify-content: center;
  overflow: hidden;
}
.gallery-main img {
  width: 100%; height: 100%;
  object-fit: contain;
  transition: opacity 200ms ease;
}
.gallery-thumbs {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 10px;
}
.gallery-thumbs figure {
  cursor: pointer;
  transition: border-color 0.2s, transform 0.2s;
}
.gallery-thumbs figure:hover {
  border-color: var(--accent);
  transform: translateY(-2px);
}
/* Active = pinned by click */
.gallery-thumbs figure.is-active {
  border: 2px solid var(--accent);
  box-shadow: 0 0 0 1px rgba(217,119,87,0.30) inset;
}

Dials

First thumb = main image. Critical. Without this, once a user clicks any other thumb, there's no way to return to the original product shot. Auto-active detection (via src equality) lights up the first thumb on page load.

Transition duration. 200ms on the main img opacity is the sweet spot. Faster = jarring scrub. Slower = sluggish.

Hover-leave restore is essential. Without the mouseleave handler, the main stays on whatever was last hovered — which feels broken when the user mouses to another part of the page entirely. The sticky restore reads as "intentional default".

Touch devices. No hover state, so the behavior collapses gracefully to click-only — the most reliable interaction on mobile anyway. No JS conditionals needed.

Gotchas worth knowing

Don't use mouseover/mouseout. They bubble through child elements; mouseenter/mouseleave fire once per element. Critical when the thumb wraps an <img>.

If thumbs live in a horizontal scroll-snap carousel (which is common — see #15 scroll-snap-horizontal), the arrows shouldn't trigger pin changes. Make sure the click handler is on the figure, not delegated to the carousel container — otherwise scroll-button clicks accidentally pin the wrong image.

Pre-load all thumb sources. Don't lazy-load thumbnails in this pattern — the hover-preview switch needs the image to already be in cache, otherwise the first hover loads + flashes blank. Use loading="eager" or no lazy attribute.

Source: Mithjem product pages (manguera-extensible-{7-5m,15m,30m}), 2026-05-24. Built after click-only felt static; user wanted to scrub thumbs without committing.