CSS Gallery: 16 Examples With Grid, Lightbox and Filters

Plenty of image galleries need nothing more than a list of images and a stylesheet. Grid, Flexbox or multi-column layout arranges the photos, and even a lightbox can run on CSS alone.

Below are 16 CSS gallery examples you can try and copy, followed by a guide to layouts, lightboxes, performance and the point where JavaScript takes over.

By Bogdan Sandu ยท Updated October 28, 2026

CSS gallery grid layouts

Six ways to lay out the same set of photos with Grid and Flexbox, from the one-line auto-fit grid to justified rows and subgrid cards. None of them needs a script.

Responsive auto-fit grid

  • repeat(auto-fit)
  • minmax() with min()
  • resize

Field library

repeat(auto-fit, minmax(min(100%, 130px), 1fr))
  • Deep blue fjord between granite cliffs, seen from a rocky ledge
  • Red sandstone ridge lit by a low sun under a cloudy sky
  • Single-track road winding below steep green cliffs
  • Tall waterfall dropping into a mossy gorge in a pine forest
  • Granite cliffs rising above pine trees and a calm river
  • Yellow tents in a snowy camp below jagged peaks
  • Terraced green hillside dropping to a calm sea
  • Hiker with a large backpack looking over hazy mountain ridges

Drag the bottom-right corner of the panel to resize it.

One declaration does the responsive work: the grid fits as many 130px tracks as the box allows and stretches them to share the leftover space. The min(100%, 130px) wrapper stops the tiles from overflowing when the box gets narrower than one track. Drag the corner of the panel to watch the column count change.

View code
.ga-fit {
  box-sizing: border-box;
  width: min(100%, 640px);
  min-width: 220px;
  max-width: 100%;
  padding: 12px;
  overflow: auto;
  resize: horizontal;
  border: 1px solid #dde1ea;
  border-radius: 18px;
  background: #fff;
  color: #10141d;
}
.ga-fit__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 6px 14px;
  margin-bottom: 14px;
}
.ga-fit__title {
  margin: 0;
  font-size: 18px;
  font-weight: 700;
}
.ga-fit__code {
  padding: 3px 8px;
  border-radius: 6px;
  background: #eef1f7;
  color: #2a3346;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
  overflow-wrap: anywhere;
}
.ga-fit__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 130px), 1fr));
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.ga-fit__grid li {
  margin: 0;
}
.ga-fit__grid img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 10px;
  background: #e7eaf0;
}

Uniform crops with aspect-ratio and object-fit

  • aspect-ratio
  • object-fit
  • :has()
Ratio
Fit
  • Single-track road winding below steep green cliffs
  • Hiker with a large backpack looking over hazy mountain ridges
  • Downtown skyscrapers at sunrise beside a wide lake
  • Old canal houses with rowboats moored along the water
  • Granite cliffs rising above pine trees and a calm river
  • Orange jellyfish trailing long red tentacles in deep blue water

aspect-ratio fixes the tile shape and object-fit decides how each photo fills it: cover crops the overflow, contain shows the whole frame with bars. The switches are plain radio inputs, and :has() on the wrapper reads which one is checked, so the demo needs no script.

View code
.ga-crop {
  box-sizing: border-box;
  width: min(100%, 720px);
  padding: 18px;
  border-radius: 18px;
  background: #fff;
  color: #141821;
  box-shadow: 0 10px 30px rgb(16 20 30 / .07);
}
.ga-crop__controls {
  display: flex;
  flex-wrap: wrap;
  gap: 12px 22px;
  margin-bottom: 16px;
}
.ga-crop__set {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
}
.ga-crop__legend {
  float: left;
  margin-right: 8px;
  padding: 0;
  color: #4a5263;
  font-size: 13px;
  font-weight: 700;
}
.ga-crop__radio {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
}
.ga-crop__radio + label {
  display: inline-flex;
  align-items: center;
  min-height: 34px;
  padding: 0 12px;
  border: 1px solid #d3d8e2;
  border-radius: 8px;
  background: #fff;
  color: #2a3140;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
  cursor: pointer;
  transition: background-color .15s ease, color .15s ease;
}
.ga-crop__radio:checked + label {
  border-color: #172036;
  background: #172036;
  color: #fff;
}
.ga-crop__radio:focus-visible + label {
  outline: 3px solid #e0731b;
  outline-offset: 2px;
}
.ga-crop__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, max(120px, 30%)), 1fr));
  align-items: start;
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.ga-crop__grid li {
  margin: 0;
}
.ga-crop__grid img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 8px;
  background: #1a1f2b;
}
.ga-crop:has(#ga-crop-1x1:checked) .ga-crop__grid img {
  aspect-ratio: 1 / 1;
}
.ga-crop:has(#ga-crop-16x9:checked) .ga-crop__grid img {
  aspect-ratio: 16 / 9;
}
.ga-crop:has(#ga-crop-3x4:checked) .ga-crop__grid img {
  aspect-ratio: 3 / 4;
}
.ga-crop:has(#ga-crop-contain:checked) .ga-crop__grid img {
  object-fit: contain;
}

Captioned figure grid

  • figure / figcaption
  • auto-fill
  • editorial type
Granite cliffs rising above pine trees and a calm river
The river before the crowdsPlate 01, 6:40 am
Old canal houses with rowboats moored along the water
Boats left for the winterPlate 02, late March
Narrow brick alley with fire escapes opening onto city towers
A shortcut to the stationPlate 03, after rain
Red sandstone ridge lit by a low sun under a cloudy sky
Ten minutes of red lightPlate 04, sunset
Single-track road winding below steep green cliffs
One lane, two hoursPlate 05, overcast
Terraced green hillside dropping to a calm sea
Terraces above the bayPlate 06, midday

Every tile is a figure with its caption underneath, so the text never sits on a busy photo and needs no hover logic. The alt text describes the picture, the caption adds what the picture cannot say, and the two never repeat each other.

View code
.ga-cap {
  box-sizing: border-box;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, max(120px, 30%)), 1fr));
  gap: 22px 14px;
  width: min(100%, 760px);
  padding: clamp(14px, 3vw, 24px);
  border-radius: 6px;
  background: #f8f4ec;
  color: #24211c;
}
.ga-cap__fig {
  margin: 0;
}
.ga-cap__fig img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  background: #e4ddd0;
}
.ga-cap__fig figcaption {
  display: grid;
  gap: 2px;
  margin-top: 10px;
  padding-top: 8px;
  border-top: 1px solid #d8cfbf;
}
.ga-cap__title {
  font-family: "Fraunces", Georgia, serif;
  font-size: 17px;
  font-weight: 500;
  line-height: 1.25;
}
.ga-cap__meta {
  color: #6a6153;
  font-size: 13px;
}

Justified rows by aspect ratio

  • flex-grow
  • custom properties
  • aspect-ratio
  • Deep blue fjord between granite cliffs, seen from a rocky ledge
  • Hiker with a large backpack looking over hazy mountain ridges
  • Downtown skyscrapers at sunrise beside a wide lake
  • Tall waterfall dropping into a mossy gorge in a pine forest
  • Red sandstone ridge lit by a low sun under a cloudy sky
  • Orange jellyfish trailing long red tentacles in deep blue water
  • Terraced green hillside dropping to a calm sea
  • Narrow brick alley with fire escapes opening onto city towers
  • Yellow tents in a snowy camp below jagged peaks
  • Single-track road winding below steep green cliffs

The Flickr-style layout without a script. Each tile gets its ratio in --ga-ar, uses it as flex-grow and as a multiple of the row height for flex-basis, so every tile in a row grows by the same height and the row edges line up. An invisible last item with a huge flex-grow keeps the final row from stretching. In production, write the ratio from the image width and height your CMS already stores.

View code
.ga-just {
  width: min(100%, 760px);
  container-type: inline-size;
}
.ga-just__list {
  --ga-row: clamp(78px, 21cqi, 150px);
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.ga-just__list::after {
  content: "";
  flex: 100000 1 0px;
}
.ga-just__item {
  flex: calc(var(--ga-ar) * 100) 1 calc(var(--ga-ar) * var(--ga-row));
  aspect-ratio: var(--ga-ar);
  margin: 0;
  overflow: hidden;
  border-radius: 4px;
  background: #dfe3ea;
}
.ga-just__item img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

Subgrid cards with aligned captions

  • grid-template-rows: subgrid
  • @supports
  • grid-row: span 4
  • Yellow tents in a snowy camp below jagged peaks

    Base camp

    Two tents, a stone wall against the wind and a view of the whole ridge.

    Day 6 4,900 m

  • Hiker with a large backpack looking over hazy mountain ridges

    The long descent into the valley of seven lakes

    Morning haze over the ridges.

    Day 7 3,150 m

  • Single-track road winding below steep green cliffs

    Back on the road

    A single-track road, passing places every few hundred meters and sheep on every one of them.

    Day 9 120 m

Each card spans four rows of the parent grid and adopts them with grid-template-rows: subgrid, so photos, titles, text and the footer line sit on shared tracks even when one title wraps to two lines. Browsers without subgrid get ordinary flex cards from the base rules.

View code
.ga-sub {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr));
  gap: 20px 16px;
  width: min(100%, 720px);
  margin: 0;
  padding: 0;
  list-style: none;
}
.ga-sub__card {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin: 0;
  padding: 10px 10px 14px;
  border: 1px solid #dbe0e8;
  border-radius: 14px;
  background: #fff;
  color: #151a24;
}
@supports (grid-template-rows: subgrid) {
  .ga-sub__card {
    display: grid;
    grid-row: span 4;
    grid-template-rows: subgrid;
    row-gap: 10px;
  }
}
.ga-sub__card img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 8px;
}
.ga-sub__title {
  margin: 0;
  padding: 0 4px;
  font-size: 17px;
  font-weight: 700;
  line-height: 1.3;
}
.ga-sub__text {
  margin: 0;
  padding: 0 4px;
  color: #4b5366;
  font-size: 14px;
  line-height: 1.5;
}
.ga-sub__meta {
  display: flex;
  justify-content: space-between;
  margin: 0;
  padding: 8px 4px 0;
  border-top: 1px dashed #cfd5e0;
  color: #1f5f4a;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
}

Lightboxes, viewers and filters

What happens when someone clicks a photo. The dialog lightbox is the one to ship when keyboard access matters; the three CSS-only versions show how far :target, radio inputs and :has() can go on their own.

Lightbox with the dialog element

  • showModal()
  • ::backdrop
  • focus return

Click a photo, then try the arrow keys and Escape.

showModal() puts the viewer in the top layer, makes the page behind it inert and closes on Escape for free. The script only swaps the image, handles the arrow keys and a backdrop click, and on close sends focus back to the thumbnail of the photo you were looking at.

View code
.ga-dlg {
  width: min(100%, 640px);
}
.ga-dlg__grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.ga-dlg__grid li {
  margin: 0;
}
.ga-dlg__thumb {
  display: block;
  width: 100%;
  padding: 0;
  overflow: hidden;
  border: 0;
  border-radius: 10px;
  background: #dfe3ea;
  cursor: zoom-in;
}
.ga-dlg__thumb img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  transition: opacity .2s ease;
}
.ga-dlg__thumb:hover img {
  opacity: .85;
}
.ga-dlg__thumb:focus-visible {
  outline: 3px solid #2457d6;
  outline-offset: 3px;
}
.ga-dlg__dialog {
  box-sizing: border-box;
  width: min(94vw, 980px, calc((94vh - 120px) * 4 / 3));
  max-width: none;
  max-height: 94vh;
  margin: auto;
  padding: 56px 0 0;
  overflow: visible;
  border: 0;
  background: transparent;
  color: #f1f2f5;
}
.ga-dlg__dialog::backdrop {
  background: rgb(9 11 16 / .9);
}
.ga-dlg__figure {
  position: relative;
  margin: 0;
}
.ga-dlg__full {
  display: block;
  width: 100%;
  height: auto;
  max-height: calc(94vh - 120px);
  object-fit: contain;
  border-radius: 6px;
}
.ga-dlg__caption {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  padding: 12px 2px 0;
  font-size: 15px;
}
.ga-dlg__count {
  color: #b9bfcc;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.ga-dlg__close,
.ga-dlg__nav {
  position: absolute;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgb(20 23 30 / .78);
  color: #fff;
  cursor: pointer;
}
.ga-dlg__close {
  top: 0;
  right: 0;
  background: #f1f2f5;
  color: #10131a;
}
.ga-dlg__nav {
  top: calc(50% + 4px);
  translate: 0 -50%;
}
.ga-dlg__nav--prev {
  left: 10px;
}
.ga-dlg__nav--next {
  right: 10px;
}
.ga-dlg__close:focus-visible,
.ga-dlg__nav:focus-visible {
  outline: 3px solid #8fb0ff;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ga-dlg__dialog {
    opacity: 0;
    transition: opacity .25s ease, overlay .25s ease allow-discrete, display .25s ease allow-discrete;
  }
  .ga-dlg__dialog[open] {
    opacity: 1;
  }
  @starting-style {
    .ga-dlg__dialog[open] {
      opacity: 0;
    }
  }
}

CSS-only lightbox with :target

  • :target
  • URL fragments
  • visibility transition
  • Red sandstone ridge lit by a low sun under a cloudy sky
  • Granite cliffs rising above pine trees and a calm river
  • Terraced green hillside dropping to a calm sea
  • Narrow brick alley with fire escapes opening onto city towers
Red sandstone ridge lit by a low sun under a cloudy sky

Red ridge 1 of 4

× ‹ ›
Granite cliffs rising above pine trees and a calm river

Valley floor 2 of 4

× ‹ ›
Terraced green hillside dropping to a calm sea

Coast terraces 3 of 4

× ‹ ›
Narrow brick alley with fire escapes opening onto city towers

Brick alley 4 of 4

× ‹ ›

Each thumbnail links to a fragment, and the overlay whose id matches it appears through :target, with previous, next and close links between the overlays. It is honest about its limits: focus stays on the thumbnail behind the overlay, Escape does nothing, every photo adds a history entry and the page jumps to the target. Here the overlay covers only the demo; on a real page use position: fixed.

View code
.ga-tgt {
  position: relative;
  width: min(100%, 560px);
  overflow: hidden;
  border-radius: 14px;
}
.ga-tgt__grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.ga-tgt__grid li {
  margin: 0;
}
.ga-tgt__thumb {
  display: block;
}
.ga-tgt__thumb img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.ga-tgt__thumb:focus-visible {
  outline: 3px solid #c2410c;
  outline-offset: -3px;
}
.ga-tgt__box {
  position: absolute;
  inset: 0;
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  place-items: center;
  gap: 8px;
  padding: 16px 60px 12px;
  background: rgb(14 12 10 / .95);
  color: #f6f1ea;
  visibility: hidden;
  opacity: 0;
  transition: opacity .25s ease, visibility 0s linear .25s;
}
.ga-tgt__box:target {
  visibility: visible;
  opacity: 1;
  transition: opacity .25s ease;
}
.ga-tgt__box img {
  display: block;
  max-width: 100%;
  max-height: 100%;
  min-height: 0;
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: 4px;
}
.ga-tgt__cap {
  display: flex;
  gap: 12px;
  margin: 0;
  font-size: 14px;
}
.ga-tgt__cap span {
  color: #c9c0b4;
}
.ga-tgt__btn {
  position: absolute;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: #f6f1ea;
  color: #1a1612;
  font-size: 28px;
  line-height: 1;
  text-decoration: none;
}
.ga-tgt__btn--close {
  top: 10px;
  right: 10px;
}
.ga-tgt__btn--prev,
.ga-tgt__btn--next {
  top: 50%;
  translate: 0 -50%;
}
.ga-tgt__btn--prev {
  left: 8px;
}
.ga-tgt__btn--next {
  right: 8px;
}
.ga-tgt__btn:focus-visible {
  outline: 3px solid #fb923c;
  outline-offset: 2px;
}

Thumbnail and main image viewer

  • radio inputs
  • general sibling ~
  • container queries
Deep blue fjord between granite cliffs, seen from a rocky ledge
Fjord ledge Archival print, 40 x 30 cm
Yellow tents in a snowy camp below jagged peaks
High camp Archival print, 40 x 30 cm
Downtown skyscrapers at sunrise beside a wide lake
First light downtown Archival print, 60 x 45 cm
Old canal houses with rowboats moored along the water
Canal in March Archival print, 40 x 30 cm
Red sandstone ridge lit by a low sun under a cloudy sky
Red ridge Archival print, 60 x 45 cm

A product-page viewer with no script. Visually hidden radio inputs hold the selected photo, the thumbnails are their labels, and sibling selectors cross-fade the matching main image. Because they are real radios, Tab reaches the group once and the arrow keys move between photos. The thumbnail rail moves to the left side when the component is wide enough.

View code
.ga-tv {
  box-sizing: border-box;
  width: min(100%, 640px);
  padding: 14px;
  border-radius: 16px;
  background: #fbfaf7;
  color: #1c1b19;
  box-shadow: 0 12px 34px rgb(28 27 25 / .1);
  container-type: inline-size;
}
.ga-tv__layout {
  position: relative;
  display: grid;
  gap: 10px;
}
.ga-tv__radio,
.ga-tv__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  opacity: 0;
}
.ga-tv__main {
  display: grid;
  min-width: 0;
}
.ga-tv__slide {
  grid-area: 1 / 1;
  margin: 0;
  visibility: hidden;
  opacity: 0;
  transition: opacity .35s ease, visibility 0s linear .35s;
}
.ga-tv__slide img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 10px;
}
.ga-tv__slide figcaption {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 2px 12px;
  padding: 10px 2px 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 18px;
}
.ga-tv__slide figcaption span {
  color: #a3441f;
  font-family: inherit;
  font-size: 14px;
}
.ga-tv__radio:nth-of-type(1):checked ~ .ga-tv__main .ga-tv__slide:nth-child(1),
.ga-tv__radio:nth-of-type(2):checked ~ .ga-tv__main .ga-tv__slide:nth-child(2),
.ga-tv__radio:nth-of-type(3):checked ~ .ga-tv__main .ga-tv__slide:nth-child(3),
.ga-tv__radio:nth-of-type(4):checked ~ .ga-tv__main .ga-tv__slide:nth-child(4),
.ga-tv__radio:nth-of-type(5):checked ~ .ga-tv__main .ga-tv__slide:nth-child(5) {
  visibility: visible;
  opacity: 1;
  transition: opacity .35s ease;
}
.ga-tv__thumbs {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 6px;
}
.ga-tv__thumb {
  display: block;
  overflow: hidden;
  border: 2px solid transparent;
  border-radius: 8px;
  cursor: pointer;
}
.ga-tv__thumb img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  opacity: .62;
  transition: opacity .2s ease;
}
.ga-tv__thumb:hover img {
  opacity: 1;
}
.ga-tv__radio:nth-of-type(1):checked ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(1),
.ga-tv__radio:nth-of-type(2):checked ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(2),
.ga-tv__radio:nth-of-type(3):checked ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(3),
.ga-tv__radio:nth-of-type(4):checked ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(4),
.ga-tv__radio:nth-of-type(5):checked ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(5) {
  border-color: #a3441f;
}
.ga-tv__radio:nth-of-type(1):checked ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(1) img,
.ga-tv__radio:nth-of-type(2):checked ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(2) img,
.ga-tv__radio:nth-of-type(3):checked ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(3) img,
.ga-tv__radio:nth-of-type(4):checked ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(4) img,
.ga-tv__radio:nth-of-type(5):checked ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(5) img {
  opacity: 1;
}
.ga-tv__radio:nth-of-type(1):focus-visible ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(1),
.ga-tv__radio:nth-of-type(2):focus-visible ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(2),
.ga-tv__radio:nth-of-type(3):focus-visible ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(3),
.ga-tv__radio:nth-of-type(4):focus-visible ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(4),
.ga-tv__radio:nth-of-type(5):focus-visible ~ .ga-tv__thumbs .ga-tv__thumb:nth-child(5) {
  outline: 3px solid #1c1b19;
  outline-offset: 2px;
}
@container (min-width: 480px) {
  .ga-tv__layout {
    grid-template-columns: 76px minmax(0, 1fr);
    align-items: start;
  }
  .ga-tv__thumbs {
    grid-row: 1;
    grid-column: 1;
    grid-template-columns: 1fr;
  }
  .ga-tv__main {
    grid-row: 1;
    grid-column: 2;
  }
}

Gallery formats and styles

Strips, walls, portfolios and a phone layout, plus a demo that shows why width and height attributes matter on lazy-loaded images.

Horizontal strip with scroll snap

  • scroll-snap-type
  • container-type
  • cqi units

Coast road, day by day

6 stops, 412 km

  • Terraced green hillside dropping to a calm sea

    Day 1 Terraces above the bay

  • Single-track road winding below steep green cliffs

    Day 2 The cliff road

  • Deep blue fjord between granite cliffs, seen from a rocky ledge

    Day 3 Ledge walk

  • Tall waterfall dropping into a mossy gorge in a pine forest

    Day 4 Inland detour

  • Old canal houses with rowboats moored along the water

    Day 5 Harbor town

  • Orange jellyfish trailing long red tentacles in deep blue water

    Day 6 Rainy day aquarium

Swipe, scroll sideways or focus the strip and use the arrow keys.

A swipeable strip that needs no script. The root is a size container, so each card is sized in cqi units against the component rather than the viewport, and the next card always peeks in to show there is more. The track is focusable, so keyboard users can scroll it with the arrow keys.

View code
.ga-strip {
  box-sizing: border-box;
  width: min(100%, 760px);
  padding: 18px 0 14px;
  border-radius: 18px;
  background: #0f3b43;
  color: #f3eee4;
  container-type: inline-size;
}
.ga-strip__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 12px;
  padding: 0 18px 12px;
}
.ga-strip__title {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
  font-weight: 600;
}
.ga-strip__sub {
  margin: 0;
  color: #b9d3d2;
  font-size: 14px;
}
.ga-strip__track {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  scroll-padding-inline: 18px;
  scrollbar-width: thin;
  scrollbar-color: #7fb3b0 transparent;
}
.ga-strip__track:focus-visible {
  outline: 3px solid #f6c453;
  outline-offset: -3px;
}
.ga-strip__list {
  display: flex;
  gap: 12px;
  width: max-content;
  margin: 0;
  padding: 0 18px 12px;
  list-style: none;
}
.ga-strip__card {
  flex: 0 0 auto;
  width: min(68cqi, 260px);
  margin: 0;
  scroll-snap-align: start;
}
.ga-strip__card img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  border-radius: 12px;
}
.ga-strip__card p {
  display: grid;
  margin: 8px 2px 0;
  color: #f6c453;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
}
.ga-strip__card p span {
  color: #f3eee4;
  font-family: "Fraunces", Georgia, serif;
  font-size: 16px;
}

Hover-zoom tiles that also work on focus

  • :focus-visible
  • @media (hover: hover)
  • scale

Hover a tile, or Tab to it.

The photo scales inside a clipped tile and a solid caption band slides up, on hover and on keyboard focus alike. Touch screens have no hover, so the caption is visible by default and only hides on devices that report hover: hover. The zoom sits behind prefers-reduced-motion: no-preference.

View code
.ga-zoom {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, max(130px, 30%)), 1fr));
  gap: 12px;
  width: min(100%, 720px);
  margin: 0;
  padding: 0;
  list-style: none;
}
.ga-zoom li {
  margin: 0;
}
.ga-zoom__tile {
  position: relative;
  display: block;
  overflow: hidden;
  border-radius: 14px;
  background: #1d1a26;
  color: #1d1a26;
  text-decoration: none;
}
.ga-zoom__tile img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 5;
  object-fit: cover;
}
.ga-zoom__cap {
  position: absolute;
  left: 8px;
  right: 8px;
  bottom: 8px;
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 0 8px;
  padding: 8px 10px;
  border-radius: 9px;
  background: #fff;
  font-size: 13px;
  line-height: 1.35;
}
.ga-zoom__cap strong {
  font-weight: 700;
}
.ga-zoom__tile:focus-visible {
  outline: 3px solid #6d28d9;
  outline-offset: 3px;
}
@media (hover: hover) {
  .ga-zoom__cap {
    opacity: 0;
  }
  .ga-zoom__tile:hover .ga-zoom__cap,
  .ga-zoom__tile:focus-visible .ga-zoom__cap {
    opacity: 1;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .ga-zoom__tile img {
    transition: scale .5s cubic-bezier(.2, .7, .2, 1);
  }
  .ga-zoom__tile:hover img,
  .ga-zoom__tile:focus-visible img {
    scale: 1.08;
  }
  .ga-zoom__cap {
    transition: opacity .25s ease, translate .3s ease;
  }
}
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .ga-zoom__cap {
    translate: 0 12px;
  }
  .ga-zoom__tile:hover .ga-zoom__cap,
  .ga-zoom__tile:focus-visible .ga-zoom__cap {
    translate: 0 0;
  }
}

Polaroid print wall

  • nth-child tilt
  • custom properties
  • ::before tape
  • Yellow tents in a snowy camp below jagged peaks
    Camp two, too cold
  • Old canal houses with rowboats moored along the water
    Our rented boat
  • Hiker with a large backpack looking over hazy mountain ridges
    Lena, summit day
  • Terraced green hillside dropping to a calm sea
    The road to the bay
  • Orange jellyfish trailing long red tentacles in deep blue water
    Rainy day plan B
  • Red sandstone ridge lit by a low sun under a cloudy sky
    Worth the 5 am alarm

Thick white borders, a handwritten-style serif caption and a strip of tape drawn with ::before. A custom property sets each print tilt through nth-child, so the order looks random without inline styles, and hover straightens and lifts a print.

View code
.ga-pol {
  box-sizing: border-box;
  width: min(100%, 760px);
  padding: clamp(18px, 4cqi, 34px) 12px;
  border-radius: 10px;
  background: #b98b5e;
  container-type: inline-size;
}
.ga-pol__list {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 22px clamp(10px, 3cqi, 26px);
  margin: 0;
  padding: 0;
  list-style: none;
}
.ga-pol__item {
  --ga-tilt: -3deg;
  margin: 0;
}
.ga-pol__item:nth-child(3n + 2) {
  --ga-tilt: 2.5deg;
}
.ga-pol__item:nth-child(3n) {
  --ga-tilt: -1deg;
}
.ga-pol__item:nth-child(4n) {
  --ga-tilt: 4deg;
}
.ga-pol__print {
  position: relative;
  width: clamp(128px, 27cqi, 190px);
  margin: 0;
  padding: 9px 9px 0;
  background: #fdfcf8;
  box-shadow: 0 2px 3px rgb(40 24 8 / .25), 0 12px 22px rgb(40 24 8 / .22);
  rotate: var(--ga-tilt);
}
.ga-pol__print::before {
  content: "";
  position: absolute;
  top: -10px;
  left: 50%;
  width: 64px;
  height: 20px;
  background: rgb(246 240 220 / .72);
  translate: -50% 0;
  rotate: calc(var(--ga-tilt) * -1.5);
}
.ga-pol__print img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 1;
  object-fit: cover;
}
.ga-pol__print figcaption {
  padding: 8px 2px 12px;
  color: #2b2823;
  font-family: "Fraunces", Georgia, serif;
  font-size: 15px;
  font-style: italic;
  line-height: 1.25;
  text-align: center;
}
@media (prefers-reduced-motion: no-preference) {
  .ga-pol__print {
    transition: rotate .3s ease, translate .3s ease, box-shadow .3s ease;
  }
  .ga-pol__print:hover {
    rotate: 0deg;
    translate: 0 -6px;
    box-shadow: 0 4px 6px rgb(40 24 8 / .2), 0 22px 36px rgb(40 24 8 / .28);
  }
}

Dark portfolio gallery

  • :has()
  • grid spans
  • focus-visible

Hover a project, or Tab through them.

A photographer portfolio on near-black, where photos carry the color. Hovering or focusing one project dims and desaturates the rest with a :has() rule on the grid, which keeps attention on one image at a time. Titles sit under the photos, so contrast never depends on the picture.

View code
.ga-pf {
  width: min(100%, 760px);
  color: #ece8e1;
  container-type: inline-size;
}
.ga-pf__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 16px;
  margin-bottom: 18px;
  padding-bottom: 14px;
  border-bottom: 1px solid #2a2f3a;
}
.ga-pf__name {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 24px;
  font-weight: 500;
  letter-spacing: -.01em;
}
.ga-pf__role {
  margin: 0;
  color: #a9a49b;
  font-size: 14px;
}
.ga-pf__grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 22px 14px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.ga-pf__item {
  margin: 0;
}
.ga-pf__item--wide {
  grid-column: span 2;
  grid-row: span 2;
}
.ga-pf__link {
  display: flex;
  flex-direction: column;
  height: 100%;
  color: inherit;
  text-decoration: none;
}
.ga-pf__link img {
  display: block;
  flex: 1;
  width: 100%;
  min-height: 0;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 3px;
  transition: opacity .3s ease, filter .3s ease;
}
.ga-pf__meta {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  padding-top: 8px;
  font-size: 14px;
}
.ga-pf__year {
  color: #a9a49b;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
}
.ga-pf__link:focus-visible {
  outline: 2px solid #e6b566;
  outline-offset: 5px;
}
.ga-pf__grid:has(.ga-pf__link:hover, .ga-pf__link:focus-visible) .ga-pf__link:not(:hover, :focus-visible) img {
  opacity: .35;
  filter: grayscale(1);
}
@container (max-width: 520px) {
  .ga-pf__grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 18px 10px;
  }
  .ga-pf__item--wide {
    grid-row: auto;
  }
}

Lazy loading with width and height to avoid CLS

  • loading="lazy"
  • width / height attributes
  • layout shift

Both columns loaded.

<img loading="lazy">

Granite cliffs rising above pine trees and a calm river Tall waterfall dropping into a mossy gorge in a pine forest

Text below the gallery jumps down when the photos arrive.

<img width="800" height="600" loading="lazy">

Granite cliffs rising above pine trees and a calm river Tall waterfall dropping into a mossy gorge in a pine forest

Text below the gallery stays where it is.

Both columns use loading="lazy", but only the right one has width and height attributes. Browsers turn those into an aspect ratio for the img, so with height: auto the box is reserved before a single byte arrives and the text below never moves. The button simulates a slow network by hiding the images for a moment, then measures how far the text jumped when they came back.

View code
.ga-cls {
  box-sizing: border-box;
  width: min(100%, 760px);
  padding: 18px;
  border-radius: 16px;
  background: #fff;
  color: #141a24;
  container-type: inline-size;
}
.ga-cls__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 14px;
  margin-bottom: 16px;
}
.ga-cls__btn {
  min-height: 44px;
  padding: 0 18px;
  border: 0;
  border-radius: 10px;
  background: #1e3a8a;
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
.ga-cls__btn:hover {
  background: #1e40af;
}
.ga-cls__btn:focus-visible {
  outline: 3px solid #f59e0b;
  outline-offset: 2px;
}
.ga-cls__status {
  margin: 0;
  color: #3f4859;
  font-size: 14px;
}
.ga-cls__cols {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 18px;
  align-items: start;
}
.ga-cls__col {
  position: relative;
  padding: 12px;
  border: 1px solid #e0e4ec;
  border-radius: 12px;
}
.ga-cls__col--sized {
  border-color: #9cc7b0;
  background: #f4faf6;
}
.ga-cls__label {
  margin: 0 0 10px;
  font-size: 12px;
}
.ga-cls__label code {
  color: #3b3f8f;
  font-family: ui-monospace, Consolas, monospace;
  overflow-wrap: anywhere;
}
.ga-cls__pics {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 6px;
}
.ga-cls__frame {
  display: block;
  border-radius: 6px;
  background: #dde3ec;
  overflow: hidden;
}
.ga-cls__frame img {
  display: block;
  width: 100%;
  height: auto;
}
.ga-cls--loading .ga-cls__col:not(.ga-cls__col--sized) img {
  display: none;
}
.ga-cls--loading .ga-cls__col--sized img {
  visibility: hidden;
}
.ga-cls__after {
  margin: 10px 0 0;
  padding: 8px 10px;
  border-radius: 6px;
  background: #fff4d6;
  color: #3d2f05;
  font-size: 14px;
  line-height: 1.4;
}
@container (max-width: 520px) {
  .ga-cls__cols {
    grid-template-columns: 1fr;
  }
}

What Counts as a CSS Gallery?

Mostly a plain list of figures. Each one holds an img and, if you want it, a figcaption. That markup gives the stylesheet everything it needs.

Layout, spacing, cropping and hover states all come from CSS rules, which keeps the page light and the gallery easy to restyle later.

Grid, column and masonry-style layouts are fair game for CSS, along with gap, sizing, image cropping, hover zoom and caption overlays. Filtering and sorting by category are not. Neither is focus handling inside a lightbox, and swipe gestures or infinite loading will push you toward JavaScript too.

The term has a second meaning. CSS gallery sites are showcase collections of well-designed websites, and this article does not cover them.

Which Layout Method Fits a CSS Gallery?

CSS Grid suits uniform thumbnails. Multi-column layout copes better with photos of mixed proportions, and native masonry is an extra for the few browsers that support it.

Flexbox sits in between. It handles a single row or a wrapping row well, though the last line needs attention.

MethodCore propertiesBest forMain limitation
CSS Gridgrid-template-columns, gapUniform thumbnails in equal rowsRows share one height, so mixed ratios get cropped
Flexboxflex-wrap, flex-basisOne row or a short wrapping rowLast row stretches when items use flex-grow
Multi-columncolumn-count, column-gapPhotos with different heightsItems run top to bottom inside each column
Native masonrydisplay: grid-lanesLane-based layout without a scriptUnflagged only in Safari (26.4 and later) so far

If every image has the same ratio, use Grid and move on. Mixed ratios where cropping is not acceptable call for multi-column, or grid-lanes with a fallback. For a single strip of thumbnails, Flexbox is enough.

WebKit’s own guidance for browsers without Grid Lanes is to load a JavaScript masonry library as a polyfill, or to fall back to another CSS layout such as multi-column. You can also write Grid Lanes with a CSS fallback, for example a display: grid line declared before display: grid-lanes.

How to Build a Responsive CSS Gallery with Grid

Start with the markup and finish by testing at narrow widths. Everything in between is a few lines of CSS.

  1. Write a ul with one li per image, each holding an img with alt text plus width and height attributes.
  2. Set display: grid on the list, then add grid-template-columns with repeat(auto-fit, minmax(...)) and a gap.
  3. Give each image width: 100% and display: block so it fills its tile.
  4. Add @media rules only where the design calls for fixed column counts.
  5. Resize the browser to 320px and check for horizontal scroll.

Fluid columns with auto-fit

One declaration does most of the responsive work.

.gallery {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
  gap: 1rem;
  list-style: none;
  padding: 0;
}

The grid fits as many 16rem columns as the container allows, then lets them grow into the leftover space.

MDN’s repeat() documentation says auto-fit collapses empty tracks after placement. Put 3 images in a wide container and auto-fit stretches the tiles to fill it, while auto-fill keeps the empty tracks around and the tiles stay narrow.

One thing that bites people. A bare minmax(16rem, 1fr) overflows when the container is narrower than 16rem, and the min(100%, 16rem) wrapper is what removes that overflow.

Fixed columns with media queries

Fixed counts beat auto-fit when the design needs exactly four, three, two or one column, because auto-fit can land on five columns at an in-between width.

Write mobile-first media queries that raise the count as the viewport widens. These are starting values, so adjust them to the images.

Viewport widthColumns
1024px and wider4
768px to 1023px3
480px to 767px2
Under 480px1
.gallery { grid-template-columns: 1fr; }

@media (min-width: 480px) {
  .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

The minmax(0, 1fr) form stops a wide image from pushing its track past an equal share.

How Do aspect-ratio and object-fit Keep Gallery Thumbnails Uniform?

The aspect-ratio property fixes the tile shape, and object-fit decides how each photo fills that shape.

.gallery img {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}

Pick the ratio from the photos you actually have. Square tiles (1 / 1) suit portfolio and product grids. 4 / 3 is a safe default for landscape-heavy sets, and 16 / 9 gives you wide tiles for video stills.

With object-fit: cover, the image fills the box and keeps its proportions, and the overflow is cropped. contain shows the whole photo and leaves empty bars around it.

Cropping removes edges. Faces or text near the border get cut, and object-position (for example center 20%) moves the crop window toward the subject.

A fixed aspect ratio on the tile also reserves its height before any file arrives, so the grid stops jumping while images load.

The web-features project lists aspect-ratio as Baseline widely available since March 2024, with support from Chrome 88, Firefox 89 and Safari 15.

MDN notes the ratio only takes effect when width or height is auto. If both are fixed, the ratio is ignored, which is an easy one to miss when a tile refuses to change shape.

How to Build a Masonry-Style Gallery in CSS

Multi-column layout builds masonry-style columns in every current browser. display: grid-lanes does the same natively in Safari 26.4 and later, while Chrome, Edge and Firefox keep it behind a flag.

Multi-column masonry

.masonry {
  column-count: 3;
  column-gap: 1rem;
}

.masonry figure {
  break-inside: avoid;
  margin: 0 0 1rem;
}

Set column-count or column-width on the container. Add break-inside: avoid to each figure, the same property MDN’s multi-column guide on content breaks applies to keep a caption attached to its image.

Items fill column one first, then column two. A viewer scanning left to right across rows sees the sequence out of order.

Ready-made CSS masonry layouts show further variations on this method, which is why the demos above leave masonry out.

Native grid-lanes and its support status

Safari 26.4 shipped Grid Lanes in March 2026, the first stable browser release with native masonry, according to the WebKit release notes. Safari 27, released in September 2026, keeps it.

.masonry {
  display: grid-lanes;
  grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
  gap: 1rem;
}

Grid Lanes reuses Grid’s track sizing and explicit placement. WebKit also added flow-tolerance, a setting for how readily an item moves to another lane to sit closest to the start.

The syntax was disputed for a while. The Chrome team argued in its own post that masonry belongs in a separate display: masonry mode, while WebKit built it into Grid. WebKit’s January 2026 status post says the key syntax decisions are final and Chromium engineers at Edge are updating their implementation.

Chrome and Edge 140 and later carry an earlier experiment behind a flag, and as of October 2026 it is still disabled by default in Chrome’s stable channel. Firefox keeps only its older grid-template-rows: masonry prototype behind a flag. Apple’s WWDC26 session “Learn CSS Grid Lanes” describes Grid Lanes as available now in Safari 26.4.

Layer it over the column layout with a feature query.

@supports (display: grid-lanes) {
  .masonry {
    display: grid-lanes;
    column-count: auto;
    grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
    gap: 1rem;
  }
  .masonry figure { margin: 0; }
}

Visitors on other browsers get the multi-column version. Nothing breaks, but the column ordering limit above still applies to them.

How to Add Hover Effects and Captions to a CSS Gallery

Wrap each image in a figure with overflow: hidden, scale the img on hover, and slide a figcaption up from the bottom edge.

.gallery figure {
  position: relative;
  overflow: hidden;
  margin: 0;
}

.gallery img {
  display: block;
  width: 100%;
  transition: transform 0.3s ease;
}

.gallery figcaption {
  position: absolute;
  inset: auto 0 0 0;
  padding: 0.75rem;
  color: #fff;
  background: linear-gradient(transparent, rgb(0 0 0 / 0.75));
  transform: translateY(100%);
  transition: transform 0.3s ease;
}

.gallery figure:hover img,
.gallery figure:focus-within img {
  transform: scale(1.06);
}

.gallery figure:hover figcaption,
.gallery figure:focus-within figcaption {
  transform: translateY(0);
}

The overflow: hidden on the figure keeps the zoomed photo inside its tile. Without it, the scaled image spills over its neighbors.

Zoom is the safe default. For tilt, blur or color shifts, browse these CSS hover effects and CSS image effects, and keep the durations short.

A 0.3s ease feels immediate, while past 0.5s the effect lags behind the pointer.

Overlay captions save vertical space and suit photo-heavy sets, but the text sits on a variable background. A caption below the image is always readable and needs no hover logic, at the price of one extra line per tile.

Touch screens have no hover, so a caption that only appears on :hover never shows. The :focus-within selectors above cover keyboard users when the image sits inside a link, and a hover query covers touch.

@media (hover: none) {
  .gallery figcaption { transform: none; }
}

The prefers-reduced-motion media feature has worked in all major browsers since January 2020 and reached Baseline widely available status in July 2022, according to the web-features project.

@media (prefers-reduced-motion: reduce) {
  .gallery img,
  .gallery figcaption { transition: none; }

  .gallery figure:hover img,
  .gallery figure:focus-within img { transform: none; }
}

Can You Build a Lightbox with Only CSS?

Yes, for small galleries. The :target pseudo-class opens a full-size image from a URL fragment, and a radio-button variant adds previous and next controls. Neither manages focus, the Escape key or swipe.

With the :target method, each thumbnail links to #photo-1 and the matching overlay appears when its id matches the fragment. The :target pseudo-class has worked across browsers since July 2015. The radio or checkbox hack does it differently, since hidden inputs hold the open state while labels act as thumbnails and as next or previous buttons.

<a href="#photo-1"><img src="thumb-1.jpg" alt="Harbor at dawn"></a>

<div class="lightbox" id="photo-1">
  <a href="#" class="close">Close</a>
  <img src="full-1.jpg" alt="Harbor at dawn">
</div>
.lightbox {
  display: none;
  position: fixed;
  inset: 0;
  place-items: center;
  background: rgb(0 0 0 / 0.9);
}

.lightbox:target { display: grid; }

There is no focus trap, so Tab moves through the page behind the open overlay, and there is no Escape key close either. Each open adds a history entry, which means Back closes the lightbox instead of leaving the page.

Fades are missing by default because display flips instantly. Getting one needs opacity and visibility in a transition, or transition-behavior: allow-discrete with @starting-style, which became Baseline in 2024.

The SLaks CSS-Gallery project builds a filterable gallery with a lightbox and previous and next links entirely in CSS and LESS. Its README still advises against building real interactive applications without JavaScript.

Use the CSS-only lightbox for 6 to 12 images on a simple page. Past that, or anywhere keyboard access matters, hand the lightbox to a script or a <dialog> element, the pattern covered on the CSS modals page.

How Do Lazy Loading, srcset and Image Formats Affect Gallery Performance?

Lazy loading cuts the bytes a gallery requests up front, srcset and sizes stop small tiles from downloading large files, and WebP or AVIF shrink each file. The first visible row still has to load eagerly.

Google’s PageSpeed Insights thresholds call an LCP of 2.5 s or less good at the 75th percentile, and a CLS of 0.1 or less counts as good too (same thresholds).

On pages that use lazy loading, the median 75th percentile LCP was 3,546 ms, against 2,922 ms on pages that do not (web.dev). Lossy WebP files come out 25% to 34% smaller than JPEG at an equal SSIM index (Google WebP compression study).

Lazy loading belongs below the fold

The web.dev gap above is a correlation, but its follow-up WordPress experiment pointed the same way. Lazy loading images already inside the viewport hurt LCP, and limiting it to below-the-fold images reversed the regression.

Put loading="lazy" on every tile after the first row. A hero image above the gallery or the first row usually becomes the LCP element, so keep it eager.

Mark the single likely LCP image with fetchpriority="high". WordPress core adds that attribute to a single image per response, and Lighthouse flags a lazy-loaded LCP image, so run it before launch.

srcset, sizes and modern formats

<picture>
  <source type="image/avif"
      srcset="photo-400.avif 400w, photo-800.avif 800w"
      sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, (min-width: 480px) 50vw, 100vw">
    <source type="image/webp"
        srcset="photo-400.webp 400w, photo-800.webp 800w"
        sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, (min-width: 480px) 50vw, 100vw">
      <img src="photo-800.jpg"
        srcset="photo-400.jpg 400w, photo-800.jpg 800w"
        sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, (min-width: 480px) 50vw, 100vw"
        width="800" height="600" loading="lazy" alt="Harbor at dawn">
    </picture>

The sizes values mirror the column counts of the grid: 25vw, 33vw, 50vw, then 100vw. A sizes string that overstates the tile width makes the browser download files larger than it needs.

The picture element lets the browser pick the first format it supports and fall back to JPEG. AVIF usually compresses photographs further than WebP but encodes more slowly, so convert at build time instead of on request.

How to Make a CSS Gallery Accessible

Useful alt text, visible keyboard focus, a DOM order that matches the intended reading path and controls large enough to hit cover most of it.

RequirementGallery elementFix
Text alternativeEach imgAlt text that describes the content
Focus visibilityLinks wrapping thumbnailsA visible :focus-visible outline
Focus Not Obscured (Minimum) (2.4.11, AA)Tile focused under a sticky headerscroll-padding-top equal to the header height
Target Size (Minimum) (2.5.8, AA)Lightbox close and arrow buttonsAt least 24 by 24 CSS pixels

W3C published WCAG 2.2 as a Recommendation on 5 October 2023 with 9 new success criteria, and 2 of them hit galleries directly. Target Size (Minimum) requires pointer targets of at least 24 by 24 CSS pixels unless spacing or another listed exception applies.

For alt text, describe what the photo shows. Use alt="" when the image is decorative. And don’t paste the caption word for word into the alt attribute, because screen readers read both.

.gallery a:focus-visible {
  outline: 3px solid #1a73e8;
  outline-offset: 3px;
}

Tab order follows the DOM, not the layout. Grid placement and the order property change what people see without changing where Tab goes, so reorder in the markup instead.

A script-driven lightbox needs role="dialog", aria-modal="true" and a label, but ARIA attributes only describe the widget. The script still has to move focus into the dialog and return it to the thumbnail on close.

Caption text over photos fails color contrast checks wherever the image is light. Put a dark gradient or a solid band behind the text and test against the lightest photo in the set.

When Does a CSS Gallery Stop Being Enough?

When the page needs filtering, sorting, swipe, infinite loading or a lightbox with focus management. Those behaviors need state and events that a stylesheet cannot hold.

Adding JavaScript for one of those jobs does not mean abandoning the layout. The grid or columns stay in CSS, and the script handles only the missing behavior.

LibrarySolvesLicense terms
PhotoSwipeTouch lightbox with swipe and zoomMIT (npm listing)
FancyboxLightbox for images, video and moreFancyapps UI license agreement (current versions); fancyBox 3 was GPLv3 with a commercial license for commercial applications
IsotopeFiltering and sortingGPLv3 for open source, commercial license for commercial projects (Metafizzy license page)

Lightbox2 covers the simple case through a data-lightbox attribute on each image link. Masonry by David DeSandro places items in row order, which multi-column cannot, so it earns its place only when that order matters.

Staying with CSS alone means no script to download, parse or maintain, and the layout renders before any JavaScript loads. The cost is no focus trapping, no Escape handling and no swipe, and filtering or sorting needs generated selectors for every combination.

Adding a library brings touch gestures, zoom and filtering out of the box. In return you get an extra download and a dependency to update, license terms on commercial sites (Isotope, Fancybox), and keyboard and screen reader behavior that depends on the library, so test it.

My preference is to keep the layout in CSS and add one narrow script for the missing behavior. PhotoSwipe fits a touch lightbox, and Isotope belongs in the build only when category filtering is a real requirement.

A full gallery plugin brings its own markup and styles that fight the grid you already wrote. Past roughly a dozen images, or at the first filtering requirement, the script earns its weight.

CSS Gallery FAQ

How do I build a slider with CSS scroll snap?

Set display: flex, overflow-x: auto and scroll-snap-type: x mandatory on the container, then add scroll-snap-align: start to each slide.

Swipe and trackpad scrolling work immediately. Arrow buttons and dots still need anchor links or a small script.

Can I filter a gallery without JavaScript?

Yes, for a handful of categories. Hidden radio inputs hold the active filter, and sibling or :has() selectors hide the tiles that do not match.

Every extra category adds selectors, which is why larger filtered galleries move to a script such as Isotope.

How do I add a CSS gallery in WordPress?

Insert a Gallery block, set the column count in its settings, and add a custom class under Advanced. Then write your grid rules for that class in Additional CSS.

The block outputs ordinary figure and img markup, so the layout techniques above apply to it.

How do I center the last row of a flexbox gallery?

Add justify-content: center to the flex container and give the items a fixed flex-basis with no flex-grow.

Items on the last row keep their width and sit in the middle instead of stretching. Full rows look the same as before.

How do I make a hexagon or 3D CSS gallery?

Hexagon tiles use clip-path: polygon() on each image, with six coordinate pairs defining the shape. A CSS clip-path generator outputs those coordinates visually.

3D effects add perspective to the container and transform: rotateY() to the tiles, which makes CSS perspective the property to learn first.

Where can I find CSS gallery code examples to copy?

CodePen hosts live, forkable gallery demos, and MDN Web Docs carries tested snippets for single techniques such as :target and repeat().

Check each demo’s license before pasting it into a commercial project, and swap the placeholder images for your own sized files.

What a New CSS Gallery Should Ship First

Launch with a grid layout, lazy-loaded thumbnails below the first row and a visible focus outline. Masonry or a script can wait until those pass testing.

Start with the grid and a fixed tile ratio. Then sort out image delivery and first-row loading, and finish with a keyboard pass at 320px in Safari and one Chromium browser.

Structure goes first because image weight and focus behavior both depend on tile size. Enhancements come last. Each one adds weight or a new way to break.

The trade-off is polish: a CSS-only lightbox gives up swipe and focus trapping in exchange for zero script. This advice dates from October 2026 and changes when Chrome and Edge ship grid-lanes without a flag, the point where the multi-column fallback can go.

Galleries that need a sliding strip of featured images lead naturally to CSS carousels and CSS slideshows.