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.
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.
<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>
(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');
});
});
})();
.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;
}
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.
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.