
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 bytes 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 images 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.
Lastly, the img is added inside this backdrop with a tabindex="0", allowing it to be focused 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 design suggestion; it can be adjusted to fit your needs perfectly.
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 the image backdrop in position: fixed when its child image is 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 the image on top of the backdrop in position: fixed when it is 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 on the image and focus the backdrop. As a result, the gallery will close the opened image.
As images are now focusable elements thanks to the tabindex="0" attribute, you can cycle between images with tab and cycle back with shift+tab.
A nice touch would be to inform the user about the controls that allow them 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 targeting only devices using a mouse, which generally implies a keyboard.
This bit will only work in browsers supporting the
:haspseudo-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.