Growing a Grid of Images: A Pure CSS Zoom Effect
CSS Grid is excellent at placing images in rows and columns. What can be more difficult is making an individual image escape its grid cell in a fluid, interactive way — like a zoom effect where the grid itself responds and adjusts. That's exactly what we can build with just CSS, no JavaScript involved.
The core trick involves making images that technically have zero dimensions inside the grid. Wait, that doesn't sound like a picture gallery at all. But it works because we also give the images minimum dimensions of 100%. Let's break down how that powers the whole effect.
A Fluid Grid Foundation
The markup stays minimal: just a container with a few images inside.
<div class="gallery">
<img>
<img>
<img>
<!-- etc. -->
</div>
Here's the CSS variables that define the image size (--s) and the gap between them (--g). The row count is implicit — CSS Grid's auto-placement algorithm will handle any number of images we throw at it.
.gallery {
--s: 150px; /* controls the size */
--g: 10px; /* controls the gap */
display: grid;
gap: var(--g);
width: calc(3*var(--s) + 2*var(--g)); /* 3 times the size plus 2 times the gap */
aspect-ratio: 1;
grid-template-columns: repeat(3, auto);
}
The browser creates implicit rows for us, so we don't need to define them. For a horizontal flow, you can also use the really explicit grid-auto-flow: column for columns instead of rows.
The hover zoom effect relies on a subtle sizing strategy:
.gallery > img {
width: 0;
height: 0;
min-height: 100%;
min-width: 100%;
object-fit: cover;
}
Giving the images width: 0 and height: 0 while also setting min-width: 100% and min-height: 100% seems like a contradiction on the surface. But here's what's happening under the hood:
- When the browser calculates the size of the grid cells, it uses the
0dimensions. Each image takes up no space, so three equal-sized cells are created based solely on the container's dimensions. - Once the grid is calculated, the browser uses the minimum dimensions to expand the images within their respective cells. This ensures the images completely fill the grid.
Making images only a visual "invisible force" like this makes sure the grid, not the image, defines its own structure. This is the crucial precursor to the zoom effect.
Triggering the Zoom on Hover
When hovering over an image, we change its width and height. We'll add a scale factor variable, --f, as a multiplier:
.gallery {
--f: 1.5; /* controls the scale factor */
}
.gallery img:hover{
width: calc(var(--s) * var(--f));
height: calc(var(--s) * var(--f));
}
So, in the resting state, all images have a width and height of 0. When one is hovered, its dimensions become calc(var(--s) * var(--f)), physically occupying a lot more space in its cell, which the grid has to accommodate. Because the load is no longer evenly distributed, the other cells get smaller to compensate for the larger one, creating the zoom effect.
The hovered image visually expands, while the rest compress smoothly. This distorted contribution creates the dynamic grid we're after. We add a transition and object-fit to keep the images sharp and unprevent distortion.
Scaling the Gallery with More Flex Variables
Once the core mechanics are clear, making the whole system flexible for any number of images is straightforward. We'll swap just the size (--s) for separate variables like the number of columns (--m) and rows (--n).
.gallery {
--n: 3; /* number of rows*/
--m: 4; /* number of columns */
--s: 150px; /* control the size */
--g: 10px; /* control the gap */
--f: 1.5; /* control the scale factor */
display: grid;
gap: var(--g);
width: calc(var(--m)*var(--s) + (var(--m) - 1)*var(--g));
height: calc(var(--n)*var(--s) + (var(--n) - 1)*var(--g));
grid-template-columns: repeat(var(--m),auto);
}
Now, grid-template-columns can create an automatic number of columns. We don't have to stick to a square, either. Two variables for width (--w) and height (--h) allow for different dimensions for the tiles.
.gallery {
--n: 3; /* number of rows*/
--m: 4; /* number of columns */
--h: 120px; /* control the height */
--w: 150px; /* control the width */
--g: 10px; /* control the gap */
--f: 1.5; /* control the scale factor */
display: grid;
gap: var(--g);
width: calc(var(--m)*var(--w) + (var(--m) - 1)*var(--g));
height: calc(var(--n)*var(--h) + (var(--n) - 1)*var(--g));
grid-template-columns: repeat(var(--m),auto);
}
.gallery img:hover{
width: calc(var(--w)*var(--f));
height: calc(var(--h)*var(--f));
}
With those in place, we gain endless variety in a code-light structure.
Filling the Whole Screen
The same technique scales up easily for a full-screen gallery. If we want N rows over the viewport's height, we use the following equation to solve for the image's intended height (--h). The formula considers the gap and the number of rows.
var(--n) * var(--h) + (var(--n) - 1) * var(--g) = 100vh
var(--m) * var(--w) + (var(--m) - 1) * var(--g) = 100vw
--w: (100vw - (var(--m) - 1) * var(--g)) / var(--m)
--h: (100vh - (var(--n) - 1) * var(--g)) / var(--n)
This simplifies to a direct 100vw and 100vh for the gallery's dimensions, removing a lot of calculation logic.
This is also the moment we can simplify things even further since we already know the container's size. Rather than calculating the height and width variables to account for gaps, we can let --f handle that extra.
Unlocking the Math: How the Grid Distributes Space
An interesting tweak is that the scale factor (--f) on hover can be less than 1. Even with a smaller value, the image becomes bigger in the grid. The key is understanding how CSS Grid distributes "free space" from the auto-placement algorithm.
With the default stretch alignment, an auto-sized column doesn't just collapse to its content; it stretches to fill the entire container. Look at a simple example with two empty divs:
With auto columns and place-content: stretch, we see two equal columns. Change the alignment to place-content: start, and the cells collapse to zero width because their content (empty divs) disappears.
As the specification says, certain values cause tracks to "be resized" (stretch). When we add content 50px to only one of the columns, it changes the whole dynamic:
This step expands tracks that have an auto max track sizing function by dividing any remaining ... free space equally amongst them.
The free space is divided equally, but the extra content still contributes to the final track width. In our image grid, multiple zero-width items and one hovered item create an uneven distributation. That's how a --f value below 1 can still make an image appear big — the browser divvies up the leftover space among them.
Here's the precise math to compute the final width of a non-hovered cell:
var(--m)*var(--w) - var(--w)*var(--f) = var(--w)*(var(--m) - var(--f))
Let’s test the formula with a concrete aim: making the hovered image precisely twice as large as its siblings in a 3-column layout. By following the logic from the final sizing to the required multiplier, we arrive at a scale factor.
(var(--m) - var(--f))/var(--m) + var(--f) = 2
var(--m)/(var(--m) - 1)
For a 3-column grid, the --f is exactly 3/2 = 1.5. Similarly, we can support different multipliers for horizontal and vertical scaling, leading to extensive customization in the image's final zoom target.
A Final Walkthrough
By fine-tuning CSS Grid's auto-placement and sizing logic, we've created a versatile zoom effect on a basic grid of images.
No clever selectors, no magic numbers, and no JavaScript — just pure CSS Grid and its default behavior. It's an ingenious little detail that paves the way for even more complex configurations, nearly all of which stem from controlling how we divide a grid container's available space among its auto-sized tracks. The secret is that the hovered image disrupts the free-space equilibrium, creating a dynamic, adaptive zoom feel.



