Skip to content

MODULE

Gallery

A wall of thumbnails and the single lightbox they all open. The wall crops every picture to one shape so it reads as a wall; the lightbox shows each one whole. Previous and next need no JavaScript at all.

Ten of Rico

Press any square, then try the arrows, or a swipe, or the scrollbar. All three work with scripting off.

HTML
<div x-ds-gallery data-label-play="Play the clips" data-label-pause="Pause the clips">
  <div class="gallery-motion">
    <button class="clip-toggle clip-toggle-inline" type="button" hidden data-ds-motion-toggle></button>
  </div>

  <div class="hairline-grid gallery-wall">
    <div class="hairline-cell hairline-cell-flush">
      <button class="gallery-thumb" command="show-modal" commandfor="rico-gallery" data-ds-item="shot-1">
        <span class="frame"><img src="/gallery/rico-01-thumb.webp" loading="lazy"
          alt="Black and white: a grinning man in a flat cap leans in while a Malinois licks his cheek." /></span>
      </button>
    </div>
    <div class="hairline-cell hairline-cell-flush">
      <button class="gallery-thumb" command="show-modal" commandfor="rico-gallery" data-ds-item="shot-2">
        <span class="frame"><img src="/gallery/rico-03-thumb.webp" loading="lazy"
          alt="A dog's muzzle almost touching the lens, mouth open, on a shaded woodland path." /></span>
      </button>
    </div>
    <div class="hairline-cell hairline-cell-flush">
      <button class="gallery-thumb" command="show-modal" commandfor="rico-gallery" data-ds-item="shot-3">
        <span class="frame"><img src="/gallery/rico-04-thumb.webp" loading="lazy"
          alt="A cluster of yellow daisy-like flowers against a bright sky. No dog in this one." /></span>
      </button>
    </div>
    <div class="hairline-cell hairline-cell-flush">
      <button class="gallery-thumb" command="show-modal" commandfor="rico-gallery" data-ds-item="shot-4">
        <span class="frame"><img src="/gallery/rico-05-thumb.webp" loading="lazy"
          alt="Under the girders of a steel road bridge, a small dog on a wide stony riverbank." /></span>
      </button>
    </div>
    <div class="hairline-cell hairline-cell-flush">
      <button class="gallery-thumb" command="show-modal" commandfor="rico-gallery" data-ds-item="shot-5">
        <span class="frame"><img src="/gallery/rico-06-thumb.webp" loading="lazy"
          alt="A soaked dog shaking itself dry on a concrete slipway, ears flying." /></span>
      </button>
    </div>
    <div class="hairline-cell hairline-cell-flush">
      <button class="gallery-thumb" command="show-modal" commandfor="rico-gallery" data-ds-item="shot-6">
        <span class="frame"><img src="/gallery/rico-09-thumb.webp" loading="lazy"
          alt="A wide river, rocks along the near bank, a dog standing ankle deep in the shallows." /></span>
      </button>
    </div>
    <div class="hairline-cell hairline-cell-flush">
      <button class="gallery-thumb" command="show-modal" commandfor="rico-gallery" data-ds-item="shot-7">
        <span class="frame"><img src="/gallery/rico-15-thumb.webp" loading="lazy"
          alt="A dog in profile against a deep blue sky, sitting in tall meadow grass." /></span>
      </button>
    </div>
    <div class="hairline-cell hairline-cell-flush">
      <button class="gallery-thumb" command="show-modal" commandfor="rico-gallery" data-ds-item="shot-8">
        <span class="frame"><img src="/gallery/rico-16-thumb.webp" loading="lazy"
          alt="The same meadow, three-quarter view, ears up, wet fur drying in the sun." /></span>
      </button>
    </div>

    <!-- A CLIP AS A GALLERY MEMBER. `frame` squares it exactly as it squares a
         photograph β€” the video needs no class of its own. It loops, and the one
         control above the grid is what makes that legal: WCAG 2.2.2 asks for A
         mechanism to stop motion, not one per thing that moves β€” which is just
         as well, because a control cannot live inside the button that opens the
         lightbox. HTML forbids a button inside a button. -->
    <div class="hairline-cell hairline-cell-flush">
      <button class="gallery-thumb" command="show-modal" commandfor="rico-gallery" data-ds-item="shot-9"
              aria-label="A clip: a dog's face filling the frame in a sunlit meadow.">
        <span class="frame clip" x-ds-clip>
          <video muted loop playsinline preload="none" poster="/gallery/rico-17-thumb.avif">
            <source src="/gallery/rico-17-thumb.webm" type="video/webm" />
            <source src="/gallery/rico-17-thumb.mp4" type="video/mp4" />
          </video>
        </span>
      </button>
    </div>
    <div class="hairline-cell hairline-cell-flush">
      <button class="gallery-thumb" command="show-modal" commandfor="rico-gallery" data-ds-item="shot-10"
              aria-label="A clip: a dog looking into the lens, then away across the grass.">
        <span class="frame clip" x-ds-clip>
          <video muted loop playsinline preload="none" poster="/gallery/rico-18-thumb.avif">
            <source src="/gallery/rico-18-thumb.webm" type="video/webm" />
            <source src="/gallery/rico-18-thumb.mp4" type="video/mp4" />
          </video>
        </span>
      </button>
    </div>
  </div>

  <dialog class="modal modal-full" id="rico-gallery">
    <div class="modal-box">
      <div class="modal-head">
        <h2 class="modal-title">Rico</h2>
        <button class="modal-close" command="close" commandfor="rico-gallery" aria-label="Close"></button>
      </div>
      <div class="modal-body modal-body-flush gallery-track scrollbar">
        <figure class="gallery-item" id="shot-1">
          <img src="/gallery/rico-01.webp" loading="lazy"
            alt="Black and white: a grinning man in a flat cap leans in while a Malinois licks his cheek." />
          <figcaption class="gallery-caption">
            Hello. <span class="gallery-count">1 / 10</span>
          </figcaption>
          <a class="gallery-nav gallery-nav-xl gallery-prev" href="#shot-10" aria-label="Previous"></a>
          <a class="gallery-nav gallery-nav-xl gallery-next" href="#shot-2" aria-label="Next"></a>
        </figure>

        <figure class="gallery-item" id="shot-2">
          <img src="/gallery/rico-03.webp" loading="lazy"
            alt="A dog's muzzle almost touching the lens, mouth open, on a shaded woodland path." />
          <figcaption class="gallery-caption">
            Too close for the lens to focus. <span class="gallery-count">2 / 10</span>
          </figcaption>
          <a class="gallery-nav gallery-nav-xl gallery-prev" href="#shot-1" aria-label="Previous"></a>
          <a class="gallery-nav gallery-nav-xl gallery-next" href="#shot-3" aria-label="Next"></a>
        </figure>

        <figure class="gallery-item" id="shot-3">
          <img src="/gallery/rico-04.webp" loading="lazy"
            alt="A cluster of yellow daisy-like flowers against a bright sky. No dog in this one." />
          <figcaption class="gallery-caption">
            Not every member of a collection is the subject of it.
            <span class="gallery-count">3 / 10</span>
          </figcaption>
          <a class="gallery-nav gallery-nav-xl gallery-prev" href="#shot-2" aria-label="Previous"></a>
          <a class="gallery-nav gallery-nav-xl gallery-next" href="#shot-4" aria-label="Next"></a>
        </figure>

        <figure class="gallery-item" id="shot-4">
          <img src="/gallery/rico-05.webp" loading="lazy"
            alt="Under the girders of a steel road bridge, a small dog on a wide stony riverbank." />
          <figcaption class="gallery-caption">
            Under the bridge. <span class="gallery-count">4 / 10</span>
          </figcaption>
          <a class="gallery-nav gallery-nav-xl gallery-prev" href="#shot-3" aria-label="Previous"></a>
          <a class="gallery-nav gallery-nav-xl gallery-next" href="#shot-5" aria-label="Next"></a>
        </figure>

        <figure class="gallery-item" id="shot-5">
          <img src="/gallery/rico-06.webp" loading="lazy"
            alt="A soaked dog shaking itself dry on a concrete slipway, ears flying." />
          <figcaption class="gallery-caption">
            Shaking the river off. <span class="gallery-count">5 / 10</span>
          </figcaption>
          <a class="gallery-nav gallery-nav-xl gallery-prev" href="#shot-4" aria-label="Previous"></a>
          <a class="gallery-nav gallery-nav-xl gallery-next" href="#shot-6" aria-label="Next"></a>
        </figure>

        <!-- The one landscape source in the set. It proves the wall: every
             thumbnail above is square, and not one of these files is. -->
        <figure class="gallery-item" id="shot-6">
          <img src="/gallery/rico-09.webp" loading="lazy"
            alt="A wide river, rocks along the near bank, a dog standing ankle deep in the shallows." />
          <figcaption class="gallery-caption">
            The only landscape frame in the set. <span class="gallery-count">6 / 10</span>
          </figcaption>
          <a class="gallery-nav gallery-nav-xl gallery-prev" href="#shot-5" aria-label="Previous"></a>
          <a class="gallery-nav gallery-nav-xl gallery-next" href="#shot-7" aria-label="Next"></a>
        </figure>

        <figure class="gallery-item" id="shot-7">
          <img src="/gallery/rico-15.webp" loading="lazy"
            alt="A dog in profile against a deep blue sky, sitting in tall meadow grass." />
          <figcaption class="gallery-caption">
            Watching something in the grass. <span class="gallery-count">7 / 10</span>
          </figcaption>
          <a class="gallery-nav gallery-nav-xl gallery-prev" href="#shot-6" aria-label="Previous"></a>
          <a class="gallery-nav gallery-nav-xl gallery-next" href="#shot-8" aria-label="Next"></a>
        </figure>

        <figure class="gallery-item" id="shot-8">
          <img src="/gallery/rico-16.webp" loading="lazy"
            alt="The same meadow, three-quarter view, ears up, wet fur drying in the sun." />
          <figcaption class="gallery-caption">
            Drying off. <span class="gallery-count">8 / 10</span>
          </figcaption>
          <a class="gallery-nav gallery-nav-xl gallery-prev" href="#shot-7" aria-label="Previous"></a>
          <a class="gallery-nav gallery-nav-xl gallery-next" href="#shot-9" aria-label="Next"></a>
        </figure>

        <!-- HERE the clip loops and has its plate, because here there is room for
             a control and a reader who chose to open it. Same component, same
             directive, different obligations from the thumbnail's. -->
        <figure class="gallery-item" id="shot-9">
          <div class="clip" x-ds-clip data-label-play="Play the clip" data-label-pause="Pause the clip">
            <video muted loop playsinline preload="none" poster="/gallery/rico-17.avif" controls>
              <source src="/gallery/rico-17.webm" type="video/webm" />
              <source src="/gallery/rico-17.mp4" type="video/mp4" />
            </video>
            <button class="clip-toggle clip-toggle-plate" type="button" hidden></button>
          </div>
          <figcaption class="gallery-caption">
            A clip, playing because you arrived at it. <span class="gallery-count">9 / 10</span>
          </figcaption>
          <a class="gallery-nav gallery-nav-xl gallery-prev" href="#shot-8" aria-label="Previous"></a>
          <a class="gallery-nav gallery-nav-xl gallery-next" href="#shot-10" aria-label="Next"></a>
        </figure>

        <figure class="gallery-item" id="shot-10">
          <div class="clip" x-ds-clip data-label-play="Play the clip" data-label-pause="Pause the clip">
            <video muted loop playsinline preload="none" poster="/gallery/rico-18.avif" controls>
              <source src="/gallery/rico-18.webm" type="video/webm" />
              <source src="/gallery/rico-18.mp4" type="video/mp4" />
            </video>
            <button class="clip-toggle clip-toggle-plate" type="button" hidden></button>
          </div>
          <figcaption class="gallery-caption">
            And the last one wraps round to the first.
            <span class="gallery-count">10 / 10</span>
          </figcaption>
          <a class="gallery-nav gallery-nav-xl gallery-prev" href="#shot-9" aria-label="Previous"></a>
          <a class="gallery-nav gallery-nav-xl gallery-next" href="#shot-1" aria-label="Next"></a>
        </figure>
      </div>
    </div>
    <form method="dialog" class="modal-veil"><button aria-label="Close"></button></form>
  </dialog>
</div>

The clean one

Plain pictures on the wall and a lightbox with no card, no caption bar and no title. Same :target browsing, so it still works with scripting off.

HTML
<!-- The clean one: plain pictures on the wall, and a lightbox with no card,
     no caption bar and no title. What is left is the veil, the artwork and the
     three controls a reader needs.

     The browsing is the same `:target` machinery the full gallery uses, so it
     still works with scripting off.

     The controls take the large rung and sit together in the trailing bottom
     corner, which is a hand's width apart rather than a screen's.

     `data-ds-item` is what makes a thumbnail open ITS OWN picture rather than
     the first one. The gallery works without it β€” that is the point of the
     `:target` design β€” but a thumbnail is a promise about which picture is
     coming, and this is what keeps it. -->
<div x-ds-gallery>
  <div class="mount-wall" style="--mount-min:170px">
    <button class="gallery-thumb" command="show-modal" commandfor="bare-wall" data-ds-item="bare-1" aria-label="Open: white marble">
      <img src="/scenery/marble.webp" alt="" loading="lazy" />
    </button>
    <button class="gallery-thumb" command="show-modal" commandfor="bare-wall" data-ds-item="bare-2" aria-label="Open: coral ink in water">
      <img src="/scenery/ink.webp" alt="" loading="lazy" />
    </button>
    <button class="gallery-thumb" command="show-modal" commandfor="bare-wall" data-ds-item="bare-3" aria-label="Open: curled paper">
      <img src="/scenery/curl.webp" alt="" loading="lazy" />
    </button>
  </div>

  <dialog class="modal modal-bare modal-full" id="bare-wall">
    <div class="modal-box">
      <div class="modal-head">
        <button class="modal-close" command="close" commandfor="bare-wall" aria-label="Close"></button>
      </div>

      <div class="modal-body modal-body-flush gallery-track gallery-corner scrollbar">
        <figure class="gallery-item" id="bare-1">
          <img src="/scenery/marble.webp" alt="White marble with grey veining" />
          <a class="gallery-nav gallery-nav-lg gallery-prev" href="#bare-3" aria-label="Previous"></a>
          <a class="gallery-nav gallery-nav-lg gallery-next" href="#bare-2" aria-label="Next"></a>
        </figure>

        <figure class="gallery-item" id="bare-2">
          <img src="/scenery/ink.webp" alt="Coral ink opening in water against pale grey" />
          <a class="gallery-nav gallery-nav-lg gallery-prev" href="#bare-1" aria-label="Previous"></a>
          <a class="gallery-nav gallery-nav-lg gallery-next" href="#bare-3" aria-label="Next"></a>
        </figure>

        <figure class="gallery-item" id="bare-3">
          <img src="/scenery/curl.webp" alt="Curled peach paper" />
          <a class="gallery-nav gallery-nav-lg gallery-prev" href="#bare-2" aria-label="Previous"></a>
          <a class="gallery-nav gallery-nav-lg gallery-next" href="#bare-1" aria-label="Next"></a>
        </figure>
      </div>
    </div>
    <form method="dialog" class="modal-veil"><button aria-label="Close"></button></form>
  </dialog>
</div>

Reference

Read from modules/gallery.css when this page was built, so it cannot fall behind the stylesheet.

CLASSES.gallery-wall.gallery-motion.gallery-thumb.gallery-track.gallery-item.gallery-caption.gallery-count.gallery-nav.gallery-prev.gallery-next.gallery-nav-lg.gallery-nav-xl.gallery-corner
SETTINGSSet the first on one element; a theme sets the second for all of them.--gallery-nav-size β€” falls back to --ds-gallery-nav
READS--ds-accent--ds-cell-thumb--ds-clip-plate--ds-focus-ring--ds-focus-ring-inset--ds-focus-width--ds-font-display--ds-gallery-nav-lg--ds-gap--ds-gap-inline--ds-hairline--ds-ink--ds-ink-faint--ds-ink-soft--ds-motion-fast--ds-pad-modal--ds-radius-control--ds-raised--ds-rule-strong--ds-sunk--ds-text-caption--ds-text-meta--ds-toggle-bar--ds-track-meta

Notes

πŸ›‘ THE CLEAN ONE, AND WHAT IT TAKES OFF

Sometimes a photograph should not arrive inside a panel. Greg's words for the version above: "focus the user on the artwork, not all the surrounding machinery". Four things come off, and none of them is a second gallery.

OFFHOW
The card behind the picturemodal-bare β€” the box gives up its border, fill and shadow
The rule under the headthe same variant. A divider with no card to divide is a line across the veil
The caption and the countermarkup. Leave gallery-caption out and there is not one
The frames on the wallmarkup again β€” a thumbnail is a gallery-thumb around whatever you like, including a bare <img>

Everything that makes a dialog correct is untouched: the top layer, Escape, the focus trap, the focus return, the veil form, and the :target browsing that works with scripting off. A clean lightbox is a subtraction, never a rewrite.

A BARE HEAD SPENDS LESS OF THE PICTURE

With no card, the head holds one 24px button and was paying --ds-pad-modal on all four sides β€” 84px of box for a control a third that tall. On a lightbox every one of those pixels comes off the artwork, which is height-limited whenever it is not much wider than it is tall.

So modal-bare tightens the block padding and leaves the inline half alone. The inline half costs the artwork nothing β€” the box is wide and the picture is centred in it β€” and keeping it lines the close up with the caption below. Measured on a square photograph at 1440Γ—900: it drew at 652 and now draws at 676.

THE CAPTION IS CENTRED, AND WAS PUSHED APART

space-between put the words hard against one edge and the counter against the other, which reads as two unrelated things stuck to the sides of a picture rather than as one line about it. Both are centred now β€” the row by justify-content and a wrapped title's second line by text-align, because those are two different questions and answering only the first looks right until somebody writes a long caption.

THREE SIZES, AND THE BASE IS THE SMALL ONE

gallery-nav was one size β€” the 96px rung it borrows from a clip's plate β€” and there was no way down from it. Greg looked at it beside a photograph and said what we had "seems to me like xl". So it is named that now, the base is the ordinary one, and the two steps up are asked for: gallery-nav-lg and gallery-nav-xl. The same shape icon and button already have.

Every step is the one before it divided by √2, in all three terms β€” the floors, the caps and the growth β€” so a rung is a rung at every container width rather than only at its cap. The chevron is a percentage of its control, so one class moves the box and the glyph together.

gallery-corner PUTS THE PAIR WHERE THE HAND ALREADY IS

Arrows at the two edges put the whole width of a photograph between them. That is right when somebody is studying one picture, and a lot of mouse when they are stepping through twenty β€” Greg's reason for wanting the other option, and his words: "it will be easier to use, without much mouse movement involved".

Previous is offset by the control's own size, read from the same property that sizes it, so the pair stays side by side on every rung without the rule knowing which rung it is. The edge placement stays the default, because a corner pair covers more of the picture.

πŸ›‘ AND THE CHEVRONS WERE NOT CENTRED

Two borders of a square cover three of its four corners, so the ink is not centred on the box the way a full square would be β€” turn it and the ink lands to one side. The correction is derived rather than guessed: with the box centred on the origin and half-side h, the drawn corners are (-h,-h), (h,-h) and (h,h); a 45Β° turn maps them to (0,-1.414h), (1.414h,0) and (0,1.414h), so the ink's middle sits 35.4% of the box to one side.

The number it replaced was -12% on both: a third of what next needed, and pushing prev further out rather than in. Greg saw it as the pair sitting "more towards the edges of the containing boxes", which is exactly what one under-corrected and one anti-corrected chevron look like.

THE ARROWS KEEP THEIR PLATES, ON PURPOSE

They are the one piece of chrome that stays, and it was tried the other way first. A chevron with no plate is drawn in ink over a photograph nobody has seen yet β€” invisible on a light one, and there is no hover to rescue it on a phone. A control the reader cannot find is worse than a control they can see, so the plates stay and the card goes.

data-ds-item IS WHAT MAKES A THUMBNAIL KEEP ITS PROMISE

Leave it off and every thumbnail opens the first picture. The gallery still works β€” that is the whole point of the :target design β€” but a thumbnail is a promise about which picture is coming, and this is the attribute that keeps it. Missed while writing this page, and the specimen above is the corrected version.

ONE DIALOG FOR THE COLLECTION, NOT ONE PER PICTURE

The obvious design is a <dialog> per item with command buttons stepping between them. It was measured and abandoned: two modal dialogs stack. Both stay open, so every "next" pushes another window onto a pile the reader then has to press Escape through one at a time. And a button carrying command has an implicit type="button", so it cannot also submit the method="dialog" form that would have closed the one behind it.

So there is one dialog holding every item in a scroll-snap track β€” and that turns out to be the version that needs no script either. Previous and next are ordinary fragment links to the neighbouring item, and a fragment link inside a top-layer dialog scrolls the track without closing it. Every picture is reachable by the arrows, by swiping, and by the scrollbar, with nothing loaded.

The wall crops. The lightbox never does.

Those are the two halves of showing a collection, and they pull in opposite directions. A wall only reads as a wall if every cell is the same shape, so the thumbnails are cropped by frame with object-fit: cover. A picture opened full size must not lose its edges, so the lightbox uses contain and letterboxes instead.

Not one of the ten source files is square β€” eight are portrait, one is landscape, and the square wall above is entirely the stylesheet's doing.

<!-- Nothing is added to the picture, and nothing to the video.
     The WALL states the shape once, and every frame under it inherits. -->
<div class="hairline-grid gallery-wall">   <!-- --ratio: 1 -->
  <span class="frame"><img …></span>       <!-- square, cropped -->
  <span class="frame clip"><video …></span><!-- square, cropped, identically -->
</div>

<!-- One frame wanting a different shape still says so, and still wins:
     a declaration on the element beats the one it inherits. -->
<span class="frame" style="--ratio: 4/5"><img …></span>

This works because frame keeps its default ratio in a var() fallback rather than declaring it on the class. A custom property declared on an element beats the one it inherits, so a frame that said --ratio: 16/9 about itself could never be told otherwise by the grid it sits in. placeholder carries the identical line: the two are geometric twins, and a layout reviewed against stand-ins must not move when the photographs arrive.

A video is an ordinary member

The last two squares are clips, and no class was added to the video to make them square. That question has already been answered by frame, which crops an img and a video with the same rule. In the lightbox the same clip is shown whole, at its own shape, and it starts playing because you arrived at it β€” x-ds-clip watches whether the video is on screen, and a slide you have not scrolled to is not. On the wall they loop; the control above the grid stops all of them at once.

A LOOP OWES EVERYONE A PAUSE, AND ONE CONTROL PAYS FOR ALL OF THEM

The thumbnails loop, and looping motion owes everyone a way to stop it β€” WCAG 2.2.2, Level A, because a loop runs past five seconds by definition. That control cannot live in a wall thumbnail: the thumbnail is the button that opens the lightbox, and HTML forbids a button inside a button.

Which turns out to be the better design anyway. 2.2.2 asks for a mechanism, not one per thing that moves β€” so a wall of thirty clips wants one control, not thirty. The button above the grid is the ordinary clip-toggle in its clip-toggle-inline form, and it works by putting data-ds-motion="paused" on the gallery.

That attribute is read by each clip rather than acted on from outside, and the difference is not academic: pausing the videos from here alone would be undone by every clip's own observer the next time it crossed the edge of the viewport. The control would appear to work until the reader scrolled.

What the caller sets

Almost nothing, and the two that exist are borrowed rather than invented β€” --cell belongs to hairline-grid and --ratio to frame. A wall does not have to be square, and nothing here would stop you.

.gallery-wallGoes on the hairline-grid. Two declarations: the track floor and a square ratio. It is not a second grid.
.gallery-thumbThe opener. A reset button that draws its own hover ring over the frame β€” a module may not repaint a component's border, so it paints beside it.
.gallery-trackGoes on the modal's body, with .modal-body-flush. The snap scroller.
.gallery-itemOne slide: the picture, then .gallery-caption with its .gallery-count, and its own pair of arrows.
.gallery-navWith .gallery-prev or .gallery-next. Links, not buttons β€” that is why they work with no script.
.hg-contact-sheet {
  --cell: 120px;      /* hairline-grid's, not the gallery's */
  --ratio: 3/2;       /* frame's β€” a wall does not have to be square */
}

THE ARROWS BELONG TO THE SLIDE, NOT TO THE TRACK

Each item carries its own pair, pointing at its own neighbours. A single fixed pair could not be a static link, because the destination changes with every step β€” and a link is the only thing that navigates without JavaScript. At rest they land in the same place on every slide, so they read as one pair that stays put.