9 CSS Image Gallery Grid Effects
CSS image gallery grid effects arrange photos into a grid or waterfall that reacts to hover and click, as pretty as the gallery you saved from Pinterest — but
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Even 2-column grid
- 02 3-column grid, cropped to match
- 03 Auto-responsive grid, no media query
- 04 Multi-column waterfall (columns)
- 05 Grid-row-span waterfall (keeps reading order)
- 06 Square grid lock (1:1)
- 07 Featured-first layout
- 08 Hover caption overlay
- 09 Click-to-open lightbox, zero JS
These nine are ordered the way you actually hit problems while building a real gallery, not by popularity. Start with the plainest even grid (01), solve the real-world mess of mismatched photo ratios (02), add a responsive layout that resizes itself (03), compare two ways to stack photos of different heights into a waterfall (04-05), lock every tile into a perfect square or blow up one hero photo (06-07), then finish with the interactions people actually click: captions and a full-size lightbox (08-09).
| # | Name | What moves | JS |
|---|---|---|---|
| 01 | Even 2-column grid | Tile translateY/scale |
None |
| 02 | 3-col grid, cropped to match | Photo scale, frame translateY |
None |
| 03 | Auto-responsive grid | Tile opacity/scale entrance |
None |
| 04 | Multi-column waterfall | Tile translateY |
None |
| 05 | Grid-row-span waterfall | Tile scale |
None |
| 06 | Square grid lock (1:1) | Photo scale, frame translateY |
None |
| 07 | Featured-first layout | Tile scale |
None |
| 08 | Hover caption overlay | Caption translateY/opacity |
None |
| 09 | Click-to-open lightbox | Panel scale/opacity |
None |
01Even 2-column grid
Four photos sit in an even 2×2 grid, and only the tile under the cursor lifts slightly with a deeper shadow to mark which one you're looking at. It's the plainest layout there is, good for a 4-6 photo portfolio row or photos scattered through a blog post.
.gallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-2;
}
.tile {
transition: transform $duration $easing, box-shadow $duration $easing;
}
.tile:hover, .tile:focus-visible {
transform: translateY(-4px) scale(1.03);
box-shadow: $shadow-raised;
}
023-col grid, cropped to match
Even when tall, wide, and square photos are mixed together, giving every image aspect-ratio: 4/3 and object-fit: cover locks every cell to the same size, and hovering zooms in only inside that frame. Built for a gallery of phone photos with mismatched ratios.
.cell {
aspect-ratio: 4 / 3;
overflow: hidden;
}
.photo {
width: 100%; height: 100%;
object-fit: cover;
transform: scale(1);
transition: transform $duration $easing;
}
.cell:hover .photo, .cell:focus-within .photo {
transform: scale(1.12);
}
03Auto-responsive grid
A single grid-template-columns: repeat(auto-fit, minmax(56px, 1fr)) line lets the column count adjust itself to the container width with zero media queries, and each new tile fades and scales into place. Good for product galleries where photo count varies by category.
.gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(56px, 1fr));
gap: $sp-2;
}
.tile {
opacity: .35;
transform: scale(.88);
transition: transform $duration $easing, opacity $duration $easing;
}
.tile.is-visible {
opacity: 1;
transform: scale(1);
}
04Multi-column waterfall
Giving five photos just columns: 2 stacks photos of different heights into a gapless waterfall down each column with zero JavaScript, and break-inside: avoid stops a photo from being sliced in half across a column break. This is the classic Pinterest-style moodboard layout.
.gallery {
columns: 2;
column-gap: $sp-2;
}
.tile {
break-inside: avoid;
margin: 0 0 $sp-2;
transition: transform $duration $easing;
}
.tile:hover, .tile:focus-visible {
transform: translateY(-3px);
}
05Grid-row-span waterfall (keeps reading order)
Slicing grid-auto-rows into a tiny unit like 4px and pre-computing each photo's own grid-row-end: span N produces a waterfall that, unlike the column version, lays photos out left-to-right in reading order instead of top-to-bottom-then-next-column. Use it when publish order matters, like a news feed.
.gallery {
display: grid;
grid-auto-flow: row dense;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 4px;
column-gap: $sp-2;
}
.tile--1 { grid-row-end: span 10; }
.tile--2 { grid-row-end: span 13; }
.tile--3 { grid-row-end: span 9; }
06Square grid lock (1:1)
Setting aspect-ratio: 1 / 1 and object-fit: cover on every tile lines up perfect squares like an Instagram feed regardless of the source photo's ratio, and hovering lifts the tile slightly while the photo inside zooms in.
.cell {
aspect-ratio: 1 / 1;
overflow: hidden;
}
.photo {
width: 100%; height: 100%;
object-fit: cover;
transform: scale(1);
transition: transform $duration $easing;
}
.cell:hover .photo {
transform: scale(1.1);
}
07Featured-first layout
Giving only the first photo grid-column: span 2 and grid-row: span 2 makes it four times larger than the rest, filling in smaller photos around it for a magazine-style layout from a single CSS selector. Good for a blog header gallery that spotlights one hero photo.
.gallery {
display: grid;
grid-template-columns: repeat(3, 56px);
grid-template-rows: repeat(2, 56px);
gap: $sp-2;
}
.tile:first-child {
grid-column: 1 / 3;
grid-row: 1 / 3;
}
08Hover caption overlay
A translucent band that's normally tucked just below the photo's edge slides fully into view via translateY on hover, bringing the caption text (title, location) up with it. Made for a travel gallery that labels each photo with a place name.
.caption {
position: absolute; left: 0; right: 0; bottom: 0;
transform: translateY(64%);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.tile:hover .caption, .tile:focus-within .caption {
transform: translateY(0);
opacity: 1;
}
09Click-to-open lightbox, zero JS
Clicking a tile opens a popover holding the full-size photo using nothing but a popovertarget attribute, ::backdrop dims the page behind it, and @starting-style makes the photo start small and transparent before growing into view. Pair <button popovertarget="lb-01"> with <div id="lb-01" popover> and the browser handles every bit of the open/close logic.
.lightbox {
opacity: 0;
transform: scale(.8);
transition: opacity $duration $easing, transform $duration $easing,
overlay $duration $easing allow-discrete, display $duration $easing allow-discrete;
@starting-style { opacity: 0; transform: scale(.8); }
}
.lightbox:popover-open { opacity: 1; transform: scale(1); }
.lightbox::backdrop { background: rgba($stage-ink, .7); opacity: 0; }
.lightbox:popover-open::backdrop { opacity: 1; }
Where it breaks — one real trap
With plain grid-auto-flow: row (no dense), the browser's placement cursor only moves forward, so a gap that opens after a tall span can stay unfilled for the rest of the layout instead of getting backfilled. Adding dense closes gaps like that by re-scanning from the top for every tile, but that same re-scan is what breaks strict DOM order: dense prioritizes filling gaps over DOM order, so depending on the span combination, a tile can render in a position that doesn't match its DOM order. This demo's own span values (10/13/9/12/10) happen to leave no gap either way, so here dense is cheap insurance rather than a fix for a visible hole — change the span numbers and that stops being true. Layout 04 (multi-column) never hits this because its order is strictly top-to-bottom-then-next-column by definition; layout 05 (grid row-span) carries the reordering risk the moment dense is switched on. If the order itself carries meaning — a publish date, say — either drop dense and accept the occasional gap, or open the vanilla folder guarded by the zip password 24p2nyh2 and hand-tune the span numbers so no gap forms in the first place.
Accessibility
With prefers-reduced-motion: reduce on, all nine turn off their transitions and loops and land straight on the finished state instead of a half-animated one. 01, 02, and 06 apply their zoom or lift instantly, 03's tiles appear already in place (full opacity, full size) with no fade-in, 04 and 05 skip the lift and sit still, 07's featured tile keeps its emphasis size without the pop, 08 leaves the caption permanently expanded, and 09's lightbox opens and closes without the scale-in transition. Item 09's trigger is a real <button>, so it opens from the keyboard (Tab, then Enter) exactly as it does from a click, and its popovertargetaction="hide" close button works the same way. The other eight items also wire their hover-only motion to :focus-visible or :focus-within so a keyboard user sees the same result a mouse user does. Browser support for the CSS Grid masonry proposal is tracked on MDN, and the Popover API's support table lives on MDN's Popover API page. If card-style hover effects are more your speed, the card hover collection and the glassmorphism collection cover that ground.
FAQ
What happens to the 09 lightbox in a browser that doesn't support the Popover API?
The popovertarget attribute is simply ignored by browsers that don't recognize it, so the button stays a plain button and clicking it does nothing — no layout breakage, no console error. Current Chrome, Edge, Safari, and Firefox already support it.
Should I use 04 or 05 for my waterfall?
If reading photos top-to-bottom-then-next-column is fine (a moodboard, a general gallery), the columns approach (04) is shorter and easier to maintain. If the layout needs to read left-to-right in strict order — a "newest first" feed, say — use the grid version (05), and budget for the dense reordering risk covered above.
How does the React version of 03's auto-responsive grid know the column count?
It doesn't compute one. AutoFitGallery never touches a resize listener or state — grid-template-columns: repeat(auto-fit, minmax()) reads only the container's width inside the browser's own layout engine, so it reflows on its own whenever the window changes size.