Skip to content

Component

gallery

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and gallery.css. No scripts.

Source

Copy both files, or run npx nojsui add gallery.

Source for gallery
<!-- .sk-gallery — mixed-height items packed into lanes, so a short item leaves
     no hole beneath it.

     A list, because a gallery is one: assistive technology announces how
     many items there are and lets a reader move through them. The layout is
     one CSS declaration on the list; nothing in the markup knows about it.

     The placeholders take a data-shape to stand in for images of different
     proportions. In real use, put an <img> with its width and height
     attributes in each figure and drop data-shape.

     role="list" looks redundant and is not: WebKit can drop a list's role
     when it is styled list-style: none, so VoiceOver would announce no list and
     no count without it. Keep it. -->
<ul class="sk-gallery" role="list" aria-label="Recent work">
  <li>
    <figure class="sk-gallery__item">
      <div class="sk-gallery__media" data-shape="portrait" aria-hidden="true"></div>
      <figcaption class="sk-gallery__caption">Harbour at dawn</figcaption>
    </figure>
  </li>
  <li>
    <figure class="sk-gallery__item">
      <div class="sk-gallery__media" data-shape="landscape" aria-hidden="true"></div>
      <figcaption class="sk-gallery__caption">Salt flats</figcaption>
    </figure>
  </li>
  <li>
    <figure class="sk-gallery__item">
      <div class="sk-gallery__media" data-shape="tall" aria-hidden="true"></div>
      <figcaption class="sk-gallery__caption">Stairwell, looking up</figcaption>
    </figure>
  </li>
  <li>
    <figure class="sk-gallery__item">
      <div class="sk-gallery__media" data-shape="square" aria-hidden="true"></div>
      <figcaption class="sk-gallery__caption">Tiles</figcaption>
    </figure>
  </li>
  <li>
    <figure class="sk-gallery__item">
      <div class="sk-gallery__media" data-shape="landscape" aria-hidden="true"></div>
      <figcaption class="sk-gallery__caption">Ferry crossing</figcaption>
    </figure>
  </li>
  <li>
    <figure class="sk-gallery__item">
      <div class="sk-gallery__media" data-shape="portrait" aria-hidden="true"></div>
      <figcaption class="sk-gallery__caption">Market stall</figcaption>
    </figure>
  </li>
  <li>
    <figure class="sk-gallery__item">
      <div class="sk-gallery__media" data-shape="square" aria-hidden="true"></div>
      <figcaption class="sk-gallery__caption">Courtyard</figcaption>
    </figure>
  </li>
  <li>
    <figure class="sk-gallery__item">
      <div class="sk-gallery__media" data-shape="tall" aria-hidden="true"></div>
      <figcaption class="sk-gallery__caption">Lighthouse</figcaption>
    </figure>
  </li>
</ul>
/* .sk-gallery — a gallery of mixed-height items that packs them into lanes.

   THE WHOLE FILE IS ONE DECLARATION: `display: grid-lanes` instead of
   `display: grid`. Everything else — the lane template, the gap — is ordinary
   grid, and it means the same thing in both. So the fallback is not a
   separate layout to maintain: an engine without lanes reads the same
   `grid-template-columns` and lays the items out in rows, each row as tall as
   its tallest item. Usable, with gaps under the shorter items. That is Tier B.

   With lanes, each item goes into whichever lane currently ends nearest the
   start, so a short item no longer leaves a hole under it. That placement is
   the one thing to know about for accessibility: the visual order can drift
   from source order, and source order is what Tab and a screen reader follow.
   See the README.

   `grid-template-columns` is not a physical property despite its name. Grid
   columns run along the inline axis, so in vertical-rl the lanes are
   horizontal strips and the gallery still packs along the block axis.

   Specs:
   - grid lanes ............ https://drafts.csswg.org/css-grid-3/#grid-lanes
   - display: grid-lanes ... https://developer.mozilla.org/docs/Web/CSS/display
   - auto-fill / minmax .... https://drafts.csswg.org/css-grid-2/#auto-repeat */

/* Public theme knobs (ADR 0011). Read, never declared. */
.sk-gallery {
  --_gap: var(--sk-gallery-gap, var(--sk-space-md));
  --_bg: var(--sk-gallery-bg, var(--sk-color-surface));
  --_border-color: var(--sk-gallery-border-color, var(--sk-color-border));
  --_radius: var(--sk-gallery-radius, var(--sk-radius-lg));
  --_caption-fg: var(--sk-gallery-caption-fg, var(--sk-color-text-subtle));
  --_media-bg: var(--sk-gallery-media-bg, var(--sk-color-accent-subtle));

  /* The fallback, and the lane template for both layouts. `min(…, 100%)`
     keeps a single lane from overflowing a container narrower than 12rem.

     The 12rem minimum lane is NOT a theme knob: it decides how many lanes
     there are, which is layout, not appearance (ADR 0011). Override this
     declaration to change it. */
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(12rem, 100%), 1fr));
  gap: var(--_gap);

  /* Without lanes, each row is as tall as its tallest item. `start` keeps a
     short item its own size at the top of the row instead of stretching it
     to fill — the gap goes underneath, where it reads as space rather than
     as a strangely tall card. */
  align-items: start;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Lanes. WebKit only when written (Safari 26.4+); Chromium and Gecko read the
   grid above. Nothing else changes: the template and gap carry over. */
@supports (display: grid-lanes) {
  .sk-gallery {
    display: grid-lanes;
  }
}

.sk-gallery__item {
  display: grid;
  overflow: clip;
  margin: 0;
  border: var(--sk-border-width) solid var(--_border-color);
  border-radius: var(--_radius);
  background: var(--_bg);
}

/* A real gallery puts an <img> here and its intrinsic size sets the height.
   Both are covered: the placeholder below takes a ratio, and an image keeps
   its own. */
.sk-gallery__media {
  display: block;
  inline-size: 100%;
  block-size: auto;
  background: var(--_media-bg);
}

.sk-gallery__caption {
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-sm);
  color: var(--_caption-fg);
  font-size: var(--sk-text-sm);
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour

   The demo has no images, so its placeholders take a shape to give the items
   the mixed heights a gallery is for. Delete these when your items are real
   images — the image's own size does this job.
--------------------------------------------------------------------------- */

.sk-gallery__media[data-shape="square"] {
  aspect-ratio: 1;
}

.sk-gallery__media[data-shape="portrait"] {
  aspect-ratio: 3 / 4;
}

.sk-gallery__media[data-shape="tall"] {
  aspect-ratio: 9 / 16;
}

.sk-gallery__media[data-shape="landscape"] {
  aspect-ratio: 4 / 3;
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.39.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Gridwidely57165210.1575210.3
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Grid laneslimited———26.4——26.4

Usage

A gallery of items with different heights — photos in their own proportions, cards with more or less text — packed so that a short item does not leave a hole beneath it. It is a list of figures, laid out by one CSS declaration.

<ul class="sk-gallery" role="list" aria-label="Recent work">
  <li>
    <figure class="sk-gallery__item">
      <img class="sk-gallery__media" src="harbour.jpg" alt="…" width="800" height="1067">
      <figcaption class="sk-gallery__caption">Harbour at dawn</figcaption>
    </figure>
  </li>
  …
</ul>

Give each <img> its width and height attributes. The browser derives the aspect ratio from them before the image loads, so the lanes are packed once rather than reshuffled as each image arrives.

How it works

One declaration

The gallery is an ordinary grid with lane-sized columns:

.sk-gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(12rem, 100%), 1fr));
  align-items: start;
}

@supports (display: grid-lanes) {
  .sk-gallery { display: grid-lanes; }
}

display: grid-lanes keeps the column template and the gap and drops the rows. Each item goes into whichever lane currently ends nearest the start, so items of different heights pack together instead of lining up in rows.

The fallback is not a second layout to maintain. An engine without lanes reads the same template and gets rows, each as tall as its tallest item. align-items: start keeps a short item at its own height at the top of its row, so the leftover space sits underneath it rather than stretching it into a strangely tall card.

Why not columns

CSS multi-column (columns: 12rem) is the usual pre-lanes way to pack mixed-height items, and it fills down each column before moving across. The first few items all land in the first column, so the visual order runs top-to-bottom while a reader expects it to run across. Lanes fill across, close to the order a grid would use. Multi-column also splits an item across two columns unless every item is told not to break.

The lane width is not a theme knob

12rem decides how many lanes fit, which is layout rather than appearance (ADR 0011). To change it, override grid-template-columns on the gallery.

Keyboard contract

None. A gallery is a layout, not a control. Anything focusable you put in an item, such as a link around the image, keeps its own behaviour.

KeyBehaviour
TabMoves through focusable content in the items in source order, not visual order — see below
Any other keyNo effect on the gallery

Verified manually in VoiceOver and NVDA: not yet — do this before marking the component done, and change this line when you have.

Accessibility notes

It is a list. A screen reader announces the number of items and lets a reader move between them. Keep the <ul>, and keep role="list" even though it looks redundant: WebKit can drop the list role from a list styled list-style: none (WebKit bug 170179), and VoiceOver then announces neither the list nor its count. Every list in the kit whose markers are hidden carries it for the same reason.

Visual order can drift from source order. Lanes put each item in the lane that ends nearest the start, so an item can appear one lane over from where it would sit in a plain grid. Tab and a screen reader follow the source. The drift is small — lanes fill across, so an item lands in roughly the place a reader expects — but it is not zero. If your items are links and their order carries meaning, such as newest first, check that the packed order still reads correctly at the widths you support. Never use order to adjust it: that moves the item visually and leaves the announced order where it was.

Captions name the figures. <figcaption> gives each <figure> its accessible name. An image inside still needs its own alt: the caption names the figure, and alt describes the picture.

The demo’s placeholders are aria-hidden. Real images replace them, and must not keep that attribute.

Degradation

Feature usedBaseline statusBehavior without it
GridwidelyThe items are a plain list, stacked one per line. Still readable, in order.
Grid lanes limitedThe same template lays the items out in rows, each as tall as its tallest item. Short items leave space beneath them; nothing overlaps or breaks. Safari 26.4+ today.

In a vertical writing mode, the lanes are horizontal strips and the gallery packs along the block axis. Grid columns follow the inline axis, so this needs nothing extra.

Theming

PropertyDefaultControls
--sk-gallery-gap--sk-space-mdSpace between items, in both directions
--sk-gallery-bg--sk-color-surfaceItem background
--sk-gallery-border-color--sk-color-borderItem border
--sk-gallery-radius--sk-radius-lgItem corner radius
--sk-gallery-caption-fg--sk-color-text-subtleCaption text
--sk-gallery-media-bg--sk-color-accent-subtleFill behind the media, visible while an image loads