Skip to content

Design notes from adding a Bento-grid showcase gallery and an accessible lightbox to the project detail page.

Project pages on a portfolio have a single job: prove the work is real. A cover image gets you partway there, but if the rest of the page is body copy and a stack list, a sceptical reader has nowhere to go for evidence. The fix is a gallery — but galleries are easy to ship and hard to ship well.

This post is the story of the one I just added to this site, and the design choices behind every layer.

If a visitor scrolls past the cover, they're not asking can this person make a webpage — they're asking is the work as good as the headline made it sound. A gallery has to answer that question without forcing the reader to do work:

  • Tell the story in the same order the project was built, so the reader sees the journey, not just the polished end state.
  • Earn the click — the thumbnails on the page have to suggest there's more worth looking at.
  • Survive on a phone. The reader who gives the most generous benefit-of-the-doubt is also the most likely to be on a 6-inch screen.
  • Be navigable from a keyboard. Anyone using the site through a screen-reader, with a switch device, or with sticky keys, doesn't get to opt out of the gallery.

The brief: a small grid that always feels curated, a viewer that disappears when the user wants to see, and zero JavaScript on the cold page.

The Bento pattern

The grid is a 6-column CSS-grid loop with a repeating size pattern — regular, regular, feature, regular, regular, tall, regular, regular. Every fifth tile is a wide "feature" cell, every sixth is a tall portrait. With anywhere from 4 to 24 images, the layout always looks like it was hand-arranged.

const PATTERN = [
  "regular",
  "regular",
  "feature",   // wide hero — every 3rd slot
  "regular",
  "regular",
  "tall",      // portrait — every 6th slot
  "regular",
  "regular",
] as const;
 
function cellFor(index: number): Cell {
  return PATTERN[index % PATTERN.length];
}

The CSS that turns those names into spans:

<li
  className={cn(
    cell === "regular" && "col-span-2 row-span-2",
    cell === "feature" &&
      "col-span-2 row-span-2 sm:col-span-4 sm:row-span-3 lg:col-span-4 lg:row-span-3",
    cell === "tall"    && "col-span-2 row-span-3",
  )}
>
  {/* tile */}
</li>

Note how feature only goes wide once we reach the sm breakpoint. On mobile, every cell collapses to half the screen — at that size, "wide" and "regular" stop reading as different sizes anyway, and stacking them defeats the layout's whole point.

Earning the click

A tile that's just an image is also a tile that has nothing to add when the reader hovers. Each gallery cell on this site does three small things in concert:

AffordanceIdle stateHover / focus state
Position counter/01, top-left, monoStays put — anchors the cell
Caption stripHidden below the tileSlides up, with a subtle gradient backdrop
Zoom indicatorHiddenAppears top-right with a thin acid border
Image transformStaticScales to 1.04 over 700ms (ease-out)

The transformations are cheap. The image is already in the layer composited for the tile, so scaling it is a GPU transform, not a layout change. The caption strip is a translate-y-full → translate-y-0 — also GPU. Nothing forces a paint outside the cell's bounds.

A wide-format prototype of a project showcase with three cells visible — a feature image, a regular thumbnail, and a tall portrait
Bento layout in motion: equal-density rows that read like a curated spread.

The lightbox is the product

Most galleries fall apart in the lightbox. The list of things that have to be right is short, but every single one of them matters:

  • ESC closes, every time, no exceptions
  • Left / right arrows navigate between images
  • Tab stays inside the lightbox until it closes
  • Body scroll locks so the page underneath doesn't shift around behind the overlay
  • Focus moves to the close button on open, and back to the originating tile on close
  • Captions are announced via aria-live="polite" so screen-reader users hear what changed when they navigate
  • Pinch-to-zoom on touch — deferred; the device already does this

The implementation lives in one client component. Here's the focus-trap fragment, the smallest piece that's most often gotten wrong:

useEffect(() => {
  const onKey = (e: KeyboardEvent) => {
    if (e.key === "Escape") return onClose();
    if (e.key === "ArrowRight") return next();
    if (e.key === "ArrowLeft") return prev();
 
    // Trap Tab inside the overlay so focus can't escape.
    if (e.key === "Tab") {
      const focusables = overlayRef.current?.querySelectorAll<HTMLElement>(
        'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
      );
      if (!focusables?.length) return;
      const first = focusables[0];
      const last = focusables[focusables.length - 1];
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  };
 
  window.addEventListener("keydown", onKey);
  return () => window.removeEventListener("keydown", onKey);
}, [next, prev, onClose]);

Captions vs. alt text

Conflating the two ruins both. Alt text is for screen readers and broken images — it should describe the image as if you couldn't see it. The caption is editorial — it says why this image is in the post. So:

  • Alt: Dashboard overview at the moment the analytics card crosses 1,000 visitors
  • Caption: Day three, just past the soft launch.

The admin gallery editor enforces alt text but leaves caption optional — a gallery image without a caption falls back to the alt text in the lightbox, which is the right behaviour for "I just dropped this in" cases without compromising on accessibility1.

Three things this gallery deliberately doesn't try to do, even though every gallery library on npm wants to bake them in:

  1. Autoplay slideshow. The reader is here because they want to see the project. They are not here for a carousel that moves on its own.
  2. Pinch-zoom inside the lightbox. The browser already handles this on every touch device. Re-implementing it is a 30 KB tax for a duplicate feature.
  3. Captions over the image. The caption deserves its own row at the bottom of the lightbox, where it can be a full sentence without fighting the photograph for attention.

Closing

Two lessons survive every revision of this kind of feature:

  1. The gallery has to feel like it was put together, not just rendered. The Bento pattern does most of that work for free.
  2. The lightbox is not a viewer — it's the product. If keyboard nav, focus management, and announcements aren't right, you've shipped a feature that excludes a meaningful chunk of your readers.

The full implementation is in src/components/projects/project-gallery.tsx if you'd like to read the rest. It's around 300 lines including comments and the focus-trap dance — small enough to understand in one sitting, large enough to be worth doing properly.

Footnotes

  1. Alt text is required for screen-reader users; a caption is editorial chrome. The W3C's WAI image-tutorial explains the distinction better than I can.

Share

[ Keep reading ]

  1. /01

    Next.js App Router: Best Practices

    March 10, 2024 · 1m
  2. /02

    Cloudflare Workers

    July 13, 2026 · 5m
  3. /03

    The TanStack Ecosystem

    May 19, 2026 · 2m