CSS Image Gallery Animation 9 One Line, Copy-Paste
A css image gallery animation turns a photo that just pops onto the page into one that reveals itself — a cover sliding off, a circle opening like a lens.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Left-to-right wipe
- 02 Curtain split reveal
- 03 Circle mask grow
- 04 Masonry stagger fade
- 05 Native <dialog> lightbox
- 06 Hover caption grid
- 07 Thumbnail to lightbox morph
- 08 Scroll mask reveal
- 09 Filter tabs via :has()
These nine are ordered the way a visitor actually moves through a photo gallery rather than by how flashy they look. First come the three single-photo reveals that fire on hover before anyone's clicked anything (01–03), then a grid of several photos arriving together (04) and two ways of enlarging one on click (05, 07), then a caption that only shows up on the tile you're pointing at (06) and a big hero photo that reveals itself as you scroll to it (08), and last, the moment a visitor starts picking a category out of many photos rather than reacting to just one (09).
01Left-to-right wipe
Hover the photo and a cream-colored panel covering it shrinks down to nothing, anchored at its right edge so the strip left uncovered grows from left to right rather than from the center. Of the nine measured for this post, this cover-panel wipe came back with the strongest motion by a wide margin — a flat panel sliding clear of a photo reads as more intense than most of the softer reveals further down this list.
.wr-cover {
position: absolute;
inset: 0;
background: $subject-cream;
transform-origin: 100% 50%;
transform: scaleX(1);
transition: transform 600ms $ease-out;
}
.wr-focal:hover .wr-cover {
transform: scaleX(0);
}
02Curtain split reveal
Two panels split the photo exactly down the middle, and hovering sends the left one sliding left and the right one sliding right until both clear the frame, like a stage curtain being pulled open. It's one of the only reveals on this list where two separate elements animate at the same time instead of one — call document.getAnimations() on it mid-hover and it hands back two running animations, where most other items here return just one.
.cs-panel {
position: absolute;
top: 0; bottom: 0;
width: 50%;
background: $subject-ink;
transition: transform 600ms $ease-spring;
}
.cs-panel--l { left: 0; }
.cs-panel--r { left: 50%; }
.cs-focal:hover .cs-panel--l { transform: translateX(-100%); }
.cs-focal:hover .cs-panel--r { transform: translateX(100%); }
03Circle mask grow
A circle starts as a single point at the exact center of the photo and grows outward on hover until its radius covers 75% of the frame, the same motion as a camera aperture letting light in. How strong this particular reveal measures depends almost entirely on the color sitting behind the photo before the circle reaches it — keep that backdrop close to the photo's own tones and the growing edge nearly disappears.
.cm-photo {
clip-path: circle(0% at 50% 50%);
transition: clip-path 600ms $ease-out;
}
.cm-focal:hover .cm-photo {
clip-path: circle(75% at 50% 50%);
}
04Masonry stagger fade
Five uneven-height tiles stack into a two-column brick pattern, and a real IntersectionObserver watches each tile on its own, adding an .in class the moment that specific tile is about 30% into view. Only a transition-delay tells the tiles apart — 0ms, then 140ms more for each one after it — so what reads as one grid animating together is actually five separate tiles running the identical opacity-and-translateY transition, just started a beat apart from each other.
var tiles = document.querySelectorAll('.mf-tile');
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); }
});
}, { threshold: 0.3 });
tiles.forEach(function (t) { io.observe(t); });
05Native dialog lightbox
Clicking the thumbnail opens a native <dialog> element with showModal() instead of a hand-built overlay div, which means the browser already handles trapping focus inside it and closing on Escape without any extra code. The close button explicitly grabs focus with preventScroll: true the instant the dialog opens, so bringing up the lightbox never yanks the page's scroll position along with it.
var dlg = document.querySelector('.lb-dialog');
var btn = document.querySelector('.lb-close');
thumb.addEventListener('click', function () {
dlg.showModal();
btn.focus({ preventScroll: true });
});
btn.addEventListener('click', function () { dlg.close(); });
06Hover caption grid
Three tiles sit side by side, and only the one under the cursor gets its caption bar rising up from the bottom edge with translateY while its opacity fades in — the other two hold perfectly still. That caption bar covers only a sliver of each tile rather than the whole photo, making this the smallest area reveal on the list by far, which the swing between fully hidden and fully shown makes up for in sharpness.
.hc-cap {
position: absolute; left: 0; right: 0; bottom: 0;
transform: translateY(100%);
opacity: 0;
transition: transform 300ms $ease-out, opacity 300ms $ease-out;
}
.hc-tile:hover .hc-cap {
transform: translateY(0);
opacity: 1;
}
07Thumbnail to lightbox morph
A click measures the thumbnail's exact position and size with getBoundingClientRect(), sets that as the overlay panel's starting transform, then clears the transform on the very next frame so the browser animates the panel growing from the thumbnail's spot out to its full centered size — the FLIP technique, short for First, Last, Invert, Play. Despite the item's name, nothing here calls document.startViewTransition(); browser support for that API is still uneven enough that the whole effect runs on plain transform instead, which every browser has agreed on for years.
thumb.addEventListener('click', function () {
var t = thumb.getBoundingClientRect();
var p = panel.getBoundingClientRect();
panel.style.transform =
'translate(' + (t.left - p.left) + 'px,' + (t.top - p.top) + 'px) ' +
'scale(' + (t.width / p.width) + ',' + (t.height / p.height) + ')';
panel.classList.add('is-open');
requestAnimationFrame(function () {
panel.style.transform = 'translate(0,0) scale(1,1)';
});
});
08Scroll mask reveal
The instant the photo is about 30% into the viewport, an IntersectionObserver adds an .in class that slides mask-position from 0 0 to 100% 0, sweeping a soft gradient edge across the image rather than cutting it with a hard line. Because the mask image here is sized at 260% — larger than the photo itself — that 100% position is the one where the mask's opaque region ends up covering the frame, not the transparent one, which runs backwards from what the number alone suggests.
.mk-photo {
mask-image: linear-gradient(100deg, transparent 0 38%, #000 58% 100%);
mask-size: 260% 100%;
mask-position: 0 0;
transition: mask-position 700ms $ease-out;
}
.mk-frame.in .mk-photo {
mask-position: 100% 0;
}
09Filter tabs via :has()
Three hidden radio inputs and a :has() selector on a shared ancestor are the entire mechanism — pick a tab and every tile whose data-cat doesn't match fades to 15% opacity and shrinks slightly, with no JavaScript running the filtering logic itself. The sliding indicator under the tab labels never drifts out of alignment with the tab it's pointing at because each tab is locked to a fixed 64px width, so its translateX only ever has to move in exact multiples of that one number.
.ft-frame:has(#ft-2:checked) .ft-tile:not([data-cat="photo"]) {
opacity: .15;
transform: scale(.94);
pointer-events: none;
}
.ft-indicator {
width: 64px;
transition: transform 300ms $ease-pop;
}
#ft-2:checked ~ .ft-tabs .ft-indicator { transform: translateX(64px); }
#ft-3:checked ~ .ft-tabs .ft-indicator { transform: translateX(128px); }
Where does a css image gallery animation actually break?
Item 08 is the one that shipped backwards the first time it was built. The natural guess when reading mask-position: 100% 0 next to mask-position: 0 0 is that a bigger number must mean more of the mask is pushed out of frame, so the first draft set the resting state to 100% and the revealed state to 0% — and it looked wrong the moment the captured frames were opened side by side, showing the photo fully hidden right when it was supposed to be fully shown. The cause is that mask-position computes its percentage the same way background-position does, sliding across the gap between the mask image's own size and the element's size; this mask is set to mask-size: 260%, so that gap is negative, and a negative gap flips which direction the percentage moves in — 100% lands the mask's opaque band over the photo instead of its transparent one, so the higher number is the one that reveals more, not less. Swapping the base and .in values the other way around fixed it, and the fix only became obvious once real rendered frames were compared rather than reasoned about from the property names alone. Item 04 carries a smaller version of the same "measure it, don't assume it" lesson: its tallest tile was tuned to fit a 480×300 desktop frame, then overflowed once the same grid ran at a 320px phone width, since the grid's own width shrinks on small screens but a tile's fixed pixel height never does. The zip below needs a password to open it, and rather than sitting in a box it's written into this paragraph as an ordinary word: 63vswp74. Copy it exactly as it appears in this sentence and the download unlocks.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Snappier wipe | Item 01's transition duration cut from 600ms to 300ms | The cover panel clears almost instantly instead of sweeping visibly across the frame |
| Full-bleed aperture | Item 03's clip-path radius raised from circle(75%) to circle(100%) |
The growing circle reaches every corner instead of leaving a rounded vignette at full size |
| Cinematic mask sweep | Item 08's mask-position transition stretched from 700ms to 2s |
The soft edge drifts slowly enough to suit a large hero photo instead of a quick thumbnail |
Accessibility for these nine image reveals
Every one of the nine switches its motion off under a reduced-motion setting, but which state they land on differs by item. For six of them — the wipe, the curtain, the circle mask, the masonry grid, the FLIP lightbox, and the scroll mask — the resting state alone already shows the whole photo, so removing the animation loses nothing: the cover panel simply starts already gone, the circle already at full radius, every masonry tile already faded in. Item 06 actually changes behavior rather than just skipping motion — its caption bar is forced to stay visible all the time instead of appearing only on hover, since a caption that can only be read by resting a cursor somewhere would otherwise become unreachable once the reveal transition is gone. Item 09's filter keeps working exactly as before under reduced motion; only the transition on the indicator and the dimmed tiles is removed, so picking a tab still narrows the gallery instantly, it just no longer slides or fades on the way there.
Item 06's hover-only trigger pairs well with the broader set of ways a photo can react to a cursor covered in CSS Image Hover Effects, and item 05's native <dialog> element gets a closer look alongside other overlay patterns in CSS Modal Animations. The mask-position property behind item 08 and the :has() selector behind item 09 are both documented on MDN, for mask-image and :has(), if you need to check browser support before shipping either one.
FAQ
Does any of this need JavaScript, or is it all pure CSS?
Four of the nine run on real JavaScript: item 04's masonry grid and item 08's scroll mask both use a 7-line IntersectionObserver, item 05's lightbox uses 6 lines to call showModal() and manage focus, and item 07's morph uses about 9 lines to measure and animate the FLIP transform. The other five — including item 09's filter tabs — run entirely on :hover, :checked, and :has(), with zero JavaScript.
Which of these still work on a phone with no mouse to hover?
Items 05, 07, and 09 are triggered by a tap rather than a hover, so they behave the same on a touchscreen as they do with a mouse. Items 01, 02, 03, and 06 only fire on :hover, which most touch browsers just never trigger — the photo underneath is still there, but the reveal itself, or item 06's caption, won't show up without a pointer resting on the tile.
Is the React version in the zip built any differently from the plain CSS one?
What's on screen matches exactly. The vanilla files keep both the real interactive path and a separate is-demo loop used only to capture the preview GIFs, while the nine React components drop that capture loop entirely and wire the same :hover states, the same IntersectionObserver calls, and the same click handlers straight into component props.