Big main image, small thumbnails below. Click a thumb to swap the main view. The pattern every real estate site uses.
Hidden radio inputs hold which slide is active. The labels styled as thumbnails are the touch targets. The :has() selector on the parent watches which radio is checked and reveals the matching slide. Pure CSS — no JS, works in iOS Quick Look.
<div class="gallery">
<input type="radio" name="g" id="g1" checked>
<input type="radio" name="g" id="g2">
...
<div class="stage">
<div class="slide s1">Living room</div>
<div class="slide s2">Kitchen</div>
...
</div>
<div class="thumbs">
<label for="g1"></label>
<label for="g2"></label>
...
</div>
</div>
.gallery .slide { position: absolute; inset: 0; opacity: 0; transition: opacity 0.4s; }
.gallery:has(#g1:checked) .s1,
.gallery:has(#g2:checked) .s2,
.gallery:has(#g3:checked) .s3 { opacity: 1; }
.thumbs label { opacity: 0.65; border: 2px solid transparent; }
.gallery:has(#g1:checked) label[for="g1"] { opacity: 1; border-color: var(--accent); }
Crossfade vs slide — swap the opacity transition for a horizontal transform: translateX if you want the images to slide instead of fade. Slide feels punchier on mobile, fade feels more premium on desktop.
Vertical thumb rail — for portrait properties, put thumbs in a left/right column instead of below. Use grid-template-columns: 80px 1fr; on the gallery container.
Zoom on hover — wrap each slide's background in an inner .slide-img div and apply transform: scale(1.05) on hover. Adds polish on desktop, harmless on mobile.
Compatibility: :has() needs Safari 15.4+, Chrome 105+, Firefox 121+. Older browsers fall back to first-slide-only — acceptable degradation. · Code playbook cross-ref: vault/Topics/web-patterns.md § Pattern 5 (Image accordion with :has()).