Skip to content

Gallery

A grid of media with a lightbox. Aspect ratios, gutters, lazy loading and keyboard traversal.

Also called Image List, Image Grid, Masonry, Photo Grid — in this system all of them are Gallery.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

One ratio beats every ratio

The uniform grid aligns in both directions and the eye goes straight to the pictures. The mixed grid has no alignment anywhere, and the ragged edges take the attention.

Uniform
Mixed

Column count and target size

Below about 80px a thumbnail stops being recognisable and becomes a coloured square. That is the floor, and it is what caps the column count on a phone.

2 columns
3 columns
4 columns

Every tile describes itself

"View image" six times identifies nothing. The description is what a screen-reader user picks from, and what appears when the file fails to load.

Described
“Deployment dashboard showing three healthy regions”“Rollback confirmation dialog”
Generic
“View image”“View image”

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Tile
Hover
Focus
Selected
Loading
✕
Failed
3 / 6
Counter
+12
Overflow

Anatomy

Every part, every measurement, and the reason it is that number.

A uniform grid of pressable tiles, each carrying its own description, opening into a modal with arrow traversal and a position counter.

  1. RatioOne, applied to all

    Square or 4:3, with object-fit: cover. Mixed ratios leave the grid with no alignment in either direction.

  2. Gutter8px

    Tight, so the set reads as one field of images. Card-sized gaps make each tile read as a separate object.

  3. Tile radius8px

    Smaller than a card. A gallery is a grid of content, not a grid of containers.

  4. Minimum tile80px

    Below this a thumbnail stops being recognisable, and the whole point of a grid is recognition before selection.

  5. Hover affordanceScrim + zoom glyph

    A scrim rather than a scale transform, because scaling a tile inside a tight grid overlaps its neighbours.

  6. LightboxModal, contain, 85% scrim

    contain, never cover — the whole image is the reason the user opened it.

  7. Position counter"3 / 6"

    The only thing telling the user how far through the set they are, and how much is left.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-surface-inset—Tile background before an image loads
--ds-layer-active—Loading skeleton
--ds-accent—Selection ring in a picker
--ds-layer-scrim—Lightbox backdrop
--ds-focus-ring—Focus outline on a tile

Spacing

TokenValueUsed for
--space-2Grid gutter

Radius

TokenValueUsed for
--radius-mdTile corners
--radius-lgThe image inside the lightbox

Motion

TokenValueUsed for
--duration-fastHover scrim

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightLabel gapMin widthMax widthWhen to use
Dense80px tiles4px——An asset picker where recognition is enough and density is the point.
Default120–200px tiles8px——The default. Three or four columns on a desktop.
Feature240px+ tiles12px——A portfolio or a photo set where the images are the content.
Mobile—4px2 columns—Two columns at 375px. Three leaves tiles below the recognition floor.
Lightbox image80vh max——90vwcontain, so the whole image is visible. Cropping the thing the user opened is the one unforgivable bug here.

Do

Crop everything to one ratioA uniform grid aligns in both directions and the eye goes to the pictures. A ragged grid spends the reader’s attention on the edges.
aria-label="View: Rollback confirmation dialog"
Give every tile its own descriptionIt is what a screen-reader user picks from, and what appears when a file fails — which in a set of images happens often enough to matter.
Esc closes·← → traverses·focus returns
Give the lightbox the full modal contractFocus trap, Escape to close, arrow keys between images, and focus returning to the thumbnail that opened it.
3 / 6
Show a position counter"3 / 6" is the only thing telling the user how far in they are and how much is left. Without it a lightbox is a corridor with no end.

Don't

Do not mix aspect ratios in a uniform gridThere is no alignment in either direction, and the ragged edges take the attention the images should be getting.
Do not use masonry for a pickerThe reading order stops matching the visual order, so keyboard focus jumps around the grid unpredictably.
Do not crop inside the lightboxThe whole image is the reason the user opened it. object-fit: cover there crops the thing they came to see.
Do not go below 80px tilesA thumbnail smaller than that is a coloured square. Recognition before selection is the entire purpose of the grid.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.1.1Non-text ContentA2.1.1KeyboardA2.1.2No Keyboard TrapA2.4.3Focus OrderA2.5.8Target Size (Minimum)AA

Contrast

  • The focus ring must be visible against an arbitrary image, which is why it is offset outside the tile rather than drawn on it.
  • A selection ring needs both an offset and a ring in the surface colour, or it disappears against a light photograph.
  • Lightbox controls sit over an unpredictable image and need their own surface or a scrim.
  • The hover scrim must be dark enough for the zoom glyph to reach 3:1 against any image beneath it.

Keyboard

TabMoves through the tiles in DOM order, which must match the visual order — the reason masonry is a problem.
Enter / SpaceOpens the lightbox and moves focus into it.
← / →Moves between images inside the lightbox.
EscCloses and returns focus to the thumbnail that opened it.
Home / EndJumps to the first or last image in the lightbox.

Screen readers

  • The grid announces its size: "list, 24 items". That is what tells a user whether to explore it at all.
  • Each tile announces its own description, so the user can choose rather than opening each one in turn.
  • Announce the position on every move: "3 of 6". Without it there is no sense of progress through the set.

Focus & touch

  • Opening the lightbox traps focus inside it; closing returns focus to the exact thumbnail that opened it, not to the first tile. Arrow traversal inside the lightbox must not move focus out of the dialog.
  • Two columns at 375px — three puts tiles below the recognition floor. Swipe between images in the lightbox, but keep the arrows: swipe alone fails WCAG 2.5.7 and is undiscoverable. Pinch-to-zoom must work inside the lightbox; that is frequently the reason someone opened it.
AttributeApplied toNotes
altEach thumbnailA real description. In a gallery this is the primary content, not an afterthought.
aria-labelEach tile button"View: Rollback confirmation dialog". "View image" six times identifies nothing.
role="dialog" aria-modalThe lightboxWith aria-label carrying the current image’s description.
aria-live="polite"The position counterAnnounces movement between images: "3 of 6".
<ul> / <li>The gridThe count is announced from the markup, which tells a screen-reader user how large the set is before they start.

Code

Example usage

tsx
1import { Gallery, GalleryItem } from '@/ui/Display'23<Gallery columns={{ base: 2, sm: 3, lg: 4 }} ratio="1 / 1" gap={8}>4  {images.map((img) => (5    <GalleryItem6      key={img.id}7      src={img.thumb}8      alt={img.alt}                 // the primary content, not an afterthought9      full={img.full}10    />11  ))}12</Gallery>1314// The lightbox owes the full modal contract: trap, Escape, arrows, and15// focus returning to the exact thumbnail that opened it.16function openAt(index: number) {17  triggerRef.current = tileRefs.current[index]18  setOpen(index)19}20function close() {21  setOpen(null)22  triggerRef.current?.focus()       // not the first tile — the right one23}2425// Preload the neighbours so arrow traversal feels instant.26React.useEffect(() => {27  if (open === null) return28  for (const i of [open - 1, open + 1]) {29    const img = images[(i + images.length) % images.length]30    if (img) new Image().src = img.full31  }32}, [open])3334// One ratio, applied to everything. Mixed ratios leave the grid with no35// alignment in either direction.36<img src={src} alt={alt} loading="lazy" decoding="async"37     className="h-full w-full object-cover" />

Framework-free HTML

html
<!-- A list, so the count is announced before the user starts exploring. -->
<ul class="ds-gallery" role="list">
  <li>
    <!-- The tile is the button and it carries the description. -->
    <button type="button" aria-label="View: Deployment dashboard showing three healthy regions">
      <img src="/thumbs/1.jpg" alt="" loading="lazy" decoding="async"
           width="400" height="400" />
    </button>
  </li>
</ul>

<div role="dialog" aria-modal="true"
     aria-label="Deployment dashboard showing three healthy regions">
  <p role="status" aria-live="polite">3 of 6</p>

  <!-- contain, never cover: the whole image is why they opened it. -->
  <img src="/full/3.jpg" alt="Deployment dashboard showing three healthy regions" />

  <button type="button" aria-label="Previous image">‹</button>
  <button type="button" aria-label="Next image">›</button>
  <button type="button" aria-label="Close">✕</button>
</div>

CSS

css
.ds-gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  /* Tight, so the set reads as one field of images rather than a grid of
     separate objects. */
  gap: 8px;
  list-style: none;
  padding: 0;
}

.ds-gallery button {
  position: relative;
  inline-size: 100%;
  /* One ratio for everything: mixed ratios leave no alignment in either
     direction. */
  aspect-ratio: 1 / 1;
  overflow: hidden;
  border-radius: var(--radius-md);
  background: var(--ds-surface-inset);
}

.ds-gallery img { inline-size: 100%; block-size: 100%; object-fit: cover; }

/* A scrim, not a scale transform — scaling a tile in a tight grid overlaps
   its neighbours. */
.ds-gallery button::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgb(0 0 0 / 0);
  transition: background 120ms;
}
.ds-gallery button:hover::after { background: rgb(0 0 0 / 0.3); }

/* Offset outside the tile, so it survives an arbitrary image beneath it. */
.ds-gallery button:focus-visible {
  outline: 2px solid var(--ds-focus-ring);
  outline-offset: 2px;
}

/* contain: cropping the image the user opened is the one unforgivable bug. */
.ds-lightbox img {
  max-inline-size: 90vw;
  max-block-size: 80vh;
  object-fit: contain;
}

@media (max-width: 480px) {
  /* Three columns at 375px puts tiles below the recognition floor. */
  .ds-gallery { grid-template-columns: repeat(2, 1fr); gap: 4px; }
}

Component API

Gallery

PropTypeDefaultDescription
columnsnumber | Record<Breakpoint, number>auto-fillTwo on a phone. Auto-fill with a minimum tile size handles most layouts.
ratiostring'1 / 1'One ratio for every tile. "auto" enables masonry, which breaks focus order.
gapnumber8Tight, so the set reads as one field of images.
lightboxbooleantrueTurn it off for a picker, where the tile selects rather than opens.
onSelect(id: string) => void—Picker mode. Selection is a ring; it is not the same as opening.

GalleryItem

PropTypeDefaultDescription
src*string—The thumbnail. Serve it at roughly twice the tile size, not the full-resolution file.
alt*string—The primary content of a gallery. It is what a non-visual user picks from.
fullstring—The full-resolution source for the lightbox, loaded only when it opens.

Notes

Professional tips

  • Serve thumbnails at roughly twice the tile size, not the full file. A grid of twenty full-resolution photographs is tens of megabytes for pictures shown at 150px.
  • Preload the neighbouring images when the lightbox opens, so arrow traversal feels instant without preloading the whole set.
  • Show a "+12" overflow tile rather than a scrolling grid when the gallery is a preview inside a card.
  • For pickers, make selection a ring and opening a separate affordance. Conflating them means every attempt to inspect an image also selects it.
  • Keep the lightbox index in the URL. A user who wants to share the third image should be able to.

Performance

  • Lazy-load everything below the fold with the native attribute and set decoding="async" so decoding does not block scrolling.
  • Virtualise past a few hundred tiles. A gallery of a thousand images will otherwise take seconds to lay out.
  • Load the full-resolution image only when the lightbox opens, and show the thumbnail scaled up until it arrives.
  • Give every tile explicit dimensions. A grid without them reflows continuously as images arrive, which is the worst possible scrolling experience.

Common mistakes

  • Mixed aspect ratios, leaving the grid with no alignment anywhere.
  • Masonry in a picker, so keyboard focus jumps unpredictably.
  • "View image" as the label on every tile.
  • object-fit: cover inside the lightbox, cropping the image the user opened.
  • Focus returning to the first tile instead of the one that was opened.
  • Tiles below 80px, where recognition fails.
  • No position counter, so the lightbox has no sense of progress.
  • Full-resolution files used as thumbnails.

Real-world recommendations

  • Users open a lightbox to see detail. If your thumbnails are already large enough to read, the gallery may not need one at all.
  • Cropping to a uniform ratio is nearly always the right call, even for photography — the alignment gain outweighs the occasional awkward crop, and the lightbox shows the full frame anyway.
  • On mobile, swipe between lightbox images is expected. Keep the arrow buttons too: swipe alone is undiscoverable and fails 2.5.7.
  • Asset pickers are galleries with selection instead of a lightbox. Keeping the two behaviours visually distinct prevents a whole class of accidental selections.