Gallery

Two ways to lay out a set of images, and one way to load them. Which layout you want comes down to a single question: are the images the same shape? If they are, use tiles. If they are not, use masonry and stop fighting it.

An auto-filling grid of squares. Every child gets aspect-ratio: 1 and its image is object-fit: cover, so the tiles line up regardless of what shape the source files are — at the cost of cropping them.

Tiles are at least 9rem; the grid fits as many as the width allows
<div class="gallery" style="max-inline-size:34rem"> <a href="#"><img src="/assets/deck/docs/sample-1.jpg" alt="A pier at dusk"></a> <a href="#"><img src="/assets/deck/docs/sample-2.jpg" alt="Frost on a window"></a> <a href="#"><img src="/assets/deck/docs/sample-3.jpg" alt="A tiled roofline"></a> <a href="#"><img src="/assets/deck/docs/sample-4.jpg" alt="Reeds in shallow water"></a> <a href="#"><img src="/assets/deck/docs/sample-5.jpg" alt="A stairwell from below"></a> </div>
<div class="gallery" style="max-inline-size:34rem">
  <a href="#"><img src="/assets/deck/docs/sample-1.jpg" alt="A pier at dusk"></a>
  <a href="#"><img src="/assets/deck/docs/sample-2.jpg" alt="Frost on a window"></a>
  <a href="#"><img src="/assets/deck/docs/sample-3.jpg" alt="A tiled roofline"></a>
  <a href="#"><img src="/assets/deck/docs/sample-4.jpg" alt="Reeds in shallow water"></a>
  <a href="#"><img src="/assets/deck/docs/sample-5.jpg" alt="A stairwell from below"></a>
</div>

Two children can break the grid: .span-2 takes two columns and two rows, .span-wide takes two columns and stays half the height. Both are useful for giving one image prominence without a second layout.

.span-2 on the first tile, .span-wide on the last
<div class="gallery" style="max-inline-size:34rem"> <a class="span-2" href="#"><img src="/assets/deck/docs/sample-1.jpg" alt="A pier at dusk"></a> <a href="#"><img src="/assets/deck/docs/sample-2.jpg" alt="Frost on a window"></a> <a href="#"><img src="/assets/deck/docs/sample-3.jpg" alt="A tiled roofline"></a> <a class="span-wide" href="#"><img src="/assets/deck/docs/sample-4.jpg" alt="Reeds in shallow water"></a> </div>
<div class="gallery" style="max-inline-size:34rem">
  <a class="span-2" href="#"><img src="/assets/deck/docs/sample-1.jpg" alt="A pier at dusk"></a>
  <a href="#"><img src="/assets/deck/docs/sample-2.jpg" alt="Frost on a window"></a>
  <a href="#"><img src="/assets/deck/docs/sample-3.jpg" alt="A tiled roofline"></a>
  <a class="span-wide" href="#"><img src="/assets/deck/docs/sample-4.jpg" alt="Reeds in shallow water"></a>
</div>

Images inside a link scale to 1.05 on hover. The tile itself is overflow: clip, so the image grows inside a fixed frame rather than pushing its neighbours around.

.masonry — ragged heights

For photographs of mixed shapes, where cropping everything square would throw away the composition. .masonry ships two implementations and picks between them at runtime.

  • CSS columns is the baseline and works everywhere. Its weakness is reading order: content flows down column one, then down column two, so item 2 is underneath item 1 rather than beside it.
  • grid-template-rows: masonry is the real thing and preserves row order. It is used automatically wherever it is supported, through a @supports block that switches columns back to auto and rebuilds the layout as a grid.

That means the reading order of your gallery changes depending on the browser. For photographs it rarely matters. If the order carries meaning — a sequence, a ranking — masonry is the wrong layout, because in half the engines the order on screen is not the order in the markup.

Heights follow the images; .masonry-3 pins it to three columns
A pier at dusk Frost on a window A tiled roofline Reeds in shallow water A stairwell from below
<div class="masonry masonry-3" style="max-inline-size:34rem"> <img src="/assets/deck/docs/sample-1.jpg" alt="A pier at dusk"> <img src="/assets/deck/docs/sample-2.jpg" alt="Frost on a window"> <img src="/assets/deck/docs/sample-3.jpg" alt="A tiled roofline"> <img src="/assets/deck/docs/sample-4.jpg" alt="Reeds in shallow water"> <img src="/assets/deck/docs/sample-5.jpg" alt="A stairwell from below"> </div>
<div class="masonry masonry-3" style="max-inline-size:34rem">
  <img src="/assets/deck/docs/sample-1.jpg" alt="A pier at dusk">
  <img src="/assets/deck/docs/sample-2.jpg" alt="Frost on a window">
  <img src="/assets/deck/docs/sample-3.jpg" alt="A tiled roofline">
  <img src="/assets/deck/docs/sample-4.jpg" alt="Reeds in shallow water">
  <img src="/assets/deck/docs/sample-5.jpg" alt="A stairwell from below">
</div>

Without a modifier the column count is responsive on its own — one column, then two at 30rem, three at 48rem, four at 72rem. .masonry-2 and .masonry-3 fix it instead. Both the count and the gap are custom properties (--cols and --gap), so an inline style handles a one-off.

.lazy — loading without the jump

.lazy is a frame with an aspect-ratio and a shimmer. It holds the space before a byte of the image arrives, so nothing on the page moves when it lands — the shimmer stops and the image fades up in place.

deck-extras.js observes each image with an IntersectionObserver at a 200px margin, swaps data-src into src, and adds .is-loaded once it decodes. Where IntersectionObserver is missing, every image is loaded immediately — a degradation to "not lazy" rather than to "broken".

data-src rather than src — the frame is what you see until it loads
<div class="gallery" style="max-inline-size:26rem"> <span class="lazy" style="--ratio:1"> <img data-src="/assets/deck/docs/sample-2.jpg" alt="Frost on a window"> </span> <span class="lazy" style="--ratio:1"> <img data-src="/assets/deck/docs/sample-3.jpg" alt="A tiled roofline"> </span> </div>
<div class="gallery" style="max-inline-size:26rem">
  <span class="lazy" style="--ratio:1">
    <img data-src="/assets/deck/docs/sample-2.jpg" alt="Frost on a window">
  </span>
  <span class="lazy" style="--ratio:1">
    <img data-src="/assets/deck/docs/sample-3.jpg" alt="A tiled roofline">
  </span>
</div>

.lazy-blur adds a blurred backdrop from the element's own background-image, for a low-resolution preview you already have; it fades out when the real image arrives. .lazy-block is unrelated to images — a dashed placeholder box for a region that is still streaming in.

A failed image shimmers forever. On an error event the script adds .is-error to the .lazy wrapper — and nothing in Deck styles that class. There is no rule for it anywhere in src/, so a broken image is visually identical to one still loading, indefinitely. Style it yourself (see Overriding it). Recorded in FINDINGS.md.

Classes

These are three separate layouts in src/24-media.css rather than one component root, so there is no completeness check on this page.

Classes, generated from the stylesheet source
Class Layer What it does Source
.gallery deck.components A simple gallery grid, when equal tiles are wanted instead 24-media.css:125
.span-2 deck.components No declarations. 24-media.css:139
.span-wide deck.components No declarations. 24-media.css:140
.masonry deck.components Two implementations. Columns is universal and good for photos where the reading order down each column is fine. Grid masonry is the real thing and preserves row order; it is used automatically where supported. 24-media.css:94
.masonry-2 deck.components Sets --cols. 24-media.css:101
.masonry-3 deck.components Sets --cols. 24-media.css:102
.lazy deck.components A placeholder that holds the space so nothing shifts, and fades the image in once it decodes. Set the aspect ratio on the frame and the layout is stable before a byte arrives. 24-media.css:147
.lazy-blur deck.components A blurred low-res preview underneath, if you have one 24-media.css:186
.lazy-block deck.components Generic content placeholder while a region streams in 24-media.css:198
.is-loaded deck.components No declarations. 24-media.css:161

Tokens

Design tokens this component reads
Token Live Declared value
--space-2 .5rem
--space-3 .75rem
--r-sm 7px
--r-md 12px
--line light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral)))
--text-faint light-dark(var(--ink-500), var(--ink-500))
--ink-300 oklch(84.6% calc(var(--chroma-neutral) * 2.2) var(--hue-neutral))
--bg-sunken light-dark(var(--ink-100), oklch(8.6% .012 var(--hue-neutral)))
--dur-3 300ms

Accessibility

  • Every image needs alt. Describe what is in the picture, not what it is called. A gallery of twelve images alt-texted "photo" is twelve identical announcements.
  • A purely decorative gallery takes alt="". Empty, not missing — an empty alt removes the image from the tree, while an absent one makes the screen reader fall back to reading the filename.
  • A linked tile needs a name that is not the image. If the <a> contains only an image, the alt becomes the link's name — so write it as the destination ("Open: a pier at dusk"), not as a caption.
  • Masonry's reading order is not stable. See above. Under CSS columns, the DOM order and the visual order disagree, so a sighted reader and a screen reader user encounter the images in different sequences. It is only acceptable where the order does not matter.
  • The shimmer is animation. It runs at 1.6s and is exempt from the reduced-motion freeze — see Reduced motion. A screenful of loading frames is a screenful of movement.
  • A broken image says nothing. With .is-error unstyled, there is no visual failure state and no announcement. If images can fail, handle it — the alt text is what a screen reader falls back to, and a sighted reader gets nothing at all.
  • .gallery crops. object-fit: cover on a square tile will cut the top and bottom off a portrait. Where the whole image matters — a diagram, a document scan — use masonry or a plain figure.

Right to left

.gallery is a grid and fills from the correct edge under dir="rtl" with no rule. .masonry in its grid form does the same.

.masonry in its columns form also reverses — CSS columns follow the inline direction — so the first item appears in the rightmost column. Both implementations mirror; they simply disagree with each other about order in the same way they do in a left-to-right document.

Reduced motion

Two things move. The tile hover scale is a normal transition and the global reset in src/02-reset.css collapses it.

The .lazy shimmer is different: it runs the same dk-shimmer keyframes as the skeleton, and although .lazy is not in the reset's exemption list, the shimmer is painted by .lazy::after — the reset targets .skeleton and six other selectors by name, so this one is neither exempted nor explicitly slowed. It is caught by the blanket animation-duration: .01ms rule, which effectively freezes it. A frozen shimmer on a loading frame reads as a stalled image, which is the exact failure the skeleton exemption exists to prevent.

So the two components that do the same job under the same keyframes behave differently under reduced motion. Recorded in FINDINGS.md.

Printing

None of these are in src/99-print.css, but two of them are handled in a second @layer deck.print block at the end of src/24-media.css:

.masonry { columns: 2; }
.lazy::after { display: none !important; }

So masonry drops to two columns on paper — four columns at print width is unreadably small — and the lazy shimmer is switched off, which stops an unloaded frame printing a grey smear over nothing.

.gallery has no print rule and keeps its auto-filling grid, which at print width usually means the same three or four columns it had on screen.

The remaining problem is one no stylesheet can solve: an image below the fold was never fetched, because browsers do not run an IntersectionObserver pass before printing. The shimmer is gone, so it prints as an empty frame rather than a smear — but it is still empty.

@media print {
  /* Two columns for tiles as well, matching what .masonry already does */
  .gallery { grid-template-columns: repeat(2, 1fr); }
}

If a page is meant to be printed with its images, load them eagerly rather than lazily. There is no CSS fix for a file that was never fetched.

Overriding it

/* One gallery, no layer needed */
<div class="masonry" style="--cols:2;--gap:1rem"> … </div>

@layer app.components {
  /* Larger tiles */
  .gallery { grid-template-columns: repeat(auto-fill, minmax(min(14rem, 100%), 1fr)); }

  /* A 3:2 gallery instead of squares */
  .gallery > * { aspect-ratio: 3 / 2; }

  /* Give the failed state something to say */
  .lazy.is-error::after { display: none; }
  .lazy.is-error {
    border: 1px dashed var(--line);
    display: grid;
    place-items: center;
  }
  .lazy.is-error::before {
    content: "Image unavailable";
    color: var(--text-faint);
    font-size: var(--text-sm);
  }
}

When not to use it

  • Not .gallery where cropping loses the point. Diagrams, screenshots and scans need to be seen whole.
  • Not .masonry where order matters. The two implementations disagree about reading order — see above.
  • Not .lazy above the fold. The hero image should be eager, and preloaded. Lazy-loading what the reader is already looking at makes the page slower, not faster.
  • Not .lazy on a page that will be printed. Unloaded images print blank.
  • Not for a carousel. If the reader is meant to move through the images one at a time, that is a carousel.
  • Not for two images. A grid of two is a cluster, and the auto-fill machinery earns nothing.