
CSS only image gallery
If you need to display a collection of images to your visitors but also want to avoid loading heavy dependencies, you may like to see what pure CSS is capable of. We can make these images zoomable with exactly 0 byte of JavaScript.
The idea is to leverage the `tabindex` attribute coupled with the `:focus` & `:focus-within` CSS pseudo selectors.
Images used are loaded from picsum.photos, which takes its sources from Unsplash.
HTML markup and basic styles #
Assuming you need to display a variable number of images, you'll have to generate a variable markup looking like this:
<div class="image-gallery image-gallery--4">
<div class="image-gallery__item">
<div class="image-gallery__backdrop" tabindex="-1">
<img
class="image-gallery__image"
src="https://picsum.photos/seed/7cb42b45/1500/750"
tabindex="0"
/>
</div>
</div>
<div class="image-gallery__item">
<div class="image-gallery__backdrop" tabindex="-1">
<img
class="image-gallery__image"
src="https://picsum.photos/seed/4dd8300c/1500/750"
tabindex="0"
/>
</div>
</div>
<div class="image-gallery__item">
<div class="image-gallery__backdrop" tabindex="-1">
<img
class="image-gallery__image"
src="https://picsum.photos/seed/b66a4549/1500/750"
tabindex="0"
/>
</div>
</div>
<div class="image-gallery__item">
<div class="image-gallery__backdrop" tabindex="-1">
<img
class="image-gallery__image"
src="https://picsum.photos/seed/5783fa29/1500/750"
tabindex="0"
/>
</div>
</div>
</div>
Note that the .image-gallery
element also has a class containing the number of image displayed inside the gallery: .image-gallery--4
. Adding this information helps a lot when styling all layout possibilities, as you'll see below in the CSS part.
Each .image-gallery__item
contains a backdrop element with tabindex="-1"
making it focusable on click but not accessible while cycling through items with the tab
key.
At last, the img
is added inside this backdrop with a tabindex="0"
allowing to focus it on click and with the tab
key.
We'll need to add some styles to this gallery:
.image-gallery {
width: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1px;
margin: 0 auto;
}
@media (min-width: 1001px) {
.image-gallery {
grid-template-columns: repeat(4, 1fr);
}
}
.image-gallery__item {
width: 100%;
height: 100%;
aspect-ratio: 1400 / 650;
}
.image-gallery--1 .image-gallery__item {
grid-column: 1 / -1;
}
@media (min-width: 1001px) {
.image-gallery:is(.image-gallery--1) {
grid-template-rows: 1fr;
}
.image-gallery:not(.image-gallery--1, .image-gallery--3) {
grid-template-rows: 250px 250px;
}
}
.image-gallery__item {
background: linear-gradient(
to bottom right,
#f5f5f5 0%,
#c5c5c5 50%,
#fff 100%
);
}
@media (min-width: 1001px) {
.image-gallery:is(.image-gallery--2, .image-gallery--3)
.image-gallery__item:not(:nth-child(1)),
.image-gallery:not(.image-gallery--1, .image-gallery--2, .image-gallery--3)
.image-gallery__item:nth-child(4),
.image-gallery:not(
.image-gallery--1,
.image-gallery--2,
.image-gallery--3,
.image-gallery--4
)
.image-gallery__item:nth-child(n + 5) {
grid-column: span 2;
}
.image-gallery:not(.image-gallery--1) .image-gallery__item:nth-child(1),
.image-gallery--2 .image-gallery__item:nth-child(2) {
grid-column: span 2;
grid-row: span 2;
}
}
.image-gallery__backdrop {
width: 100%;
height: 100%;
transition: background-color 160ms;
z-index: 1;
}
.image-gallery__image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
cursor: zoom-in;
user-select: none;
outline: none;
}
We now have a nice responsive image gallery handling any number of images:

This is of course only a suggestion of design, it can be adjusted to fit perfectly your needs.
Making it interactive #
We can now focus on making these images zoomable! And that's the beauty of CSS, with only two sets of rules, the magic happens.
This one will display in position: fixed
the image backdrop when its child image will be focused:
.image-gallery__backdrop:not(:focus):focus-within {
position: fixed;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.1);
cursor: zoom-out;
z-index: 2;
user-select: none;
}
And this one will also display in position: fixed
the image on top of the backdrop when focused:
.image-gallery__image:focus {
width: auto;
max-width: 90%;
height: auto;
max-height: 90%;
object-fit: contain;
border-radius: 8px;
pointer-events: none;
z-index: 3;
animation: image-gallery-zoom-in 160ms;
}
@keyframes image-gallery-zoom-in {
0% {
opacity: 0;
transform: scale(0.98);
}
}
The pointer-events: none
property will allow clicks to go through, which will make the user lose focus over the image and focus the backdrop. In consequence, the gallery will close the opened image.
As images are now focusable elements thanks to the tabindex="0"
attribute, you cycle between images with tab
and cycle back with shift
+tab
.
A nice touch would be to inform the user about the controls allowing to cycle through the images. It would of course need to be displayed only if an image is focused and not on a mobile device.
You can add this piece of HTML at the end of the .image-gallery
container. It must be located inside this container in order to have a parent selector with :has
.
<div class="image-gallery__keyboard-indicator">
Use <code>Tab</code> or <code>Shift</code>+<code>Tab</code> to navigate between images
</div>
.image-gallery__keyboard-indicator {
display: none;
position: fixed;
bottom: 26px;
left: 50%;
padding: 8px 12px;
font-size: 14px;
background-color: #f5f5f5;
border-radius: 4px;
transform: translateX(-50%);
z-index: 4;
}
@media (pointer: fine) {
.image-gallery:has(.image-gallery__image:focus)
.image-gallery__keyboard-indicator {
display: block;
}
}
.image-gallery__keyboard-indicator > code {
padding: 2px 4px;
font-size: calc(1rem - 2px);
background-color: #fff;
border: 1px solid #c2c2c2;
border-radius: 4px;
}
This indicator will be displayed only when the gallery is focused, thanks to .image-gallery:has(.image-gallery__image:focus)
.
The @media (pointer: fine)
allows for targeting only device using a mouse, which generally brings its keyboard.
This bit will only work in browsers supporting
:has
pseudo selector so we'll call it progressive enhancement.
Result #
Sure, it is less comfortable than the experience provided by a full-blown Lightbox plugin, but it comes at absolutely no cost.
If your needs are simple, start with the simplest solution you can come up with.