Media

Gallery

A responsive grid of pictures, uniform or masonry, with captions, that opens each in the lightbox.

Built on
<ul><button><dialog>Invoker commandsCSS gridCSS columns
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.0 kB
Built from
Lightbox
Code
"use client";

import { Gallery, GalleryItem } from "@/components/equip/gallery";

/* Stand-in photos drawn from the tokens, so the demo needs no image files. */
const shots = [
  { caption: "Harbour at dawn", sky: "var(--eq-chart-2)", land: "var(--eq-chart-4)" },
  { caption: "Fish market", sky: "var(--eq-chart-1)", land: "var(--eq-chart-5)" },
  { caption: "Lighthouse path", sky: "var(--eq-chart-3)", land: "var(--eq-chart-2)" },
  { caption: "Low tide", sky: "var(--eq-chart-4)", land: "var(--eq-chart-1)" },
  { caption: "Dunes", sky: "var(--eq-chart-5)", land: "var(--eq-chart-3)" },
  { caption: "Evening ferry", sky: "var(--eq-chart-1)", land: "var(--eq-chart-2)" },
];

export default function Demo() {
  return (
    <Gallery label="Coast trip" captions="hidden" minWidth={60} style={{ width: 212 }}>
      {shots.map((shot, i) => (
        <GalleryItem key={shot.caption} caption={shot.caption}>
          <svg viewBox="0 0 120 80" width="1200" height="800" preserveAspectRatio="xMidYMid slice" role="img" aria-label={shot.caption}>
            <rect width="120" height="80" style={{ fill: `color-mix(in srgb, ${shot.sky} 35%, var(--eq-raised))` }} />
            <circle cx={30 + i * 12} cy="26" r="10" style={{ fill: shot.sky }} />
            <path d={`M0 ${58 - i * 2} Q40 ${40 + i * 3} 70 56 T120 50 V80 H0Z`} style={{ fill: `color-mix(in srgb, ${shot.land} 70%, var(--eq-raised))` }} />
          </svg>
        </GalleryItem>
      ))}
    </Gallery>
  );
}

Examples

Uniform grid with captions

One tab stop for the whole grid: the arrow keys move between pictures, Enter opens the viewer, and closing it lands on the picture last shown.

Code
"use client";

import { Gallery, GalleryItem } from "@/components/equip/gallery";

const rooms = [
  { caption: "Living room, south light", tone: "var(--eq-chart-4)", plan: [[10, 10, 60, 40], [70, 10, 40, 60]] },
  { caption: "Kitchen with island", tone: "var(--eq-chart-1)", plan: [[10, 10, 100, 25], [40, 45, 40, 20]] },
  { caption: "Main bedroom", tone: "var(--eq-chart-3)", plan: [[10, 10, 70, 60], [85, 10, 25, 30]] },
  { caption: "Bathroom", tone: "var(--eq-chart-2)", plan: [[10, 10, 45, 60], [60, 40, 50, 30]] },
  { caption: "Balcony", tone: "var(--eq-chart-5)", plan: [[10, 30, 100, 40]] },
];

export default function Example() {
  return (
    <Gallery label="Flat 4B, Canal Street" minWidth={150}>
      {rooms.map((room) => (
        <GalleryItem key={room.caption} caption={room.caption}>
          <svg viewBox="0 0 120 80" width="1200" height="800" preserveAspectRatio="xMidYMid slice" role="img" aria-label={room.caption}>
            <rect width="120" height="80" style={{ fill: `color-mix(in srgb, ${room.tone} 22%, var(--eq-raised))` }} />
            {room.plan.map(([x, y, w, h]) => (
              <rect key={`${x}-${y}`} x={x} y={y} width={w} height={h} rx="2" style={{ fill: `color-mix(in srgb, ${room.tone} 60%, var(--eq-raised))` }} />
            ))}
          </svg>
        </GalleryItem>
      ))}
    </Gallery>
  );
}

Masonry, mixed shapes

Each picture keeps its own shape. Pictures run down each column, so tab and screen reader order follow the columns; the arrow keys move by position on screen.

Code
"use client";

import { Gallery, GalleryItem } from "@/components/equip/gallery";

const pieces = [
  { caption: "Poster, spring season", w: 80, h: 120, tone: "var(--eq-chart-1)" },
  { caption: "Ticket design", w: 120, h: 60, tone: "var(--eq-chart-2)" },
  { caption: "Programme cover", w: 90, h: 120, tone: "var(--eq-chart-3)" },
  { caption: "Banner, foyer", w: 120, h: 50, tone: "var(--eq-chart-4)" },
  { caption: "Social square", w: 100, h: 100, tone: "var(--eq-chart-5)" },
  { caption: "Flyer, back", w: 80, h: 110, tone: "var(--eq-chart-2)" },
  { caption: "Season mark", w: 100, h: 80, tone: "var(--eq-chart-1)" },
];

export default function Example() {
  return (
    <Gallery label="Spring season artwork" layout="masonry" captions="overlay" minWidth={130} loop>
      {pieces.map((piece) => (
        <GalleryItem key={piece.caption} caption={piece.caption}>
          <svg viewBox={`0 0 ${piece.w} ${piece.h}`} width={piece.w * 8} height={piece.h * 8} role="img" aria-label={piece.caption}>
            <rect width={piece.w} height={piece.h} style={{ fill: `color-mix(in srgb, ${piece.tone} 30%, var(--eq-raised))` }} />
            <circle cx={piece.w / 2} cy={piece.h / 2.4} r={Math.min(piece.w, piece.h) / 4} style={{ fill: piece.tone }} />
            <rect x="8" y={piece.h - 16} width={piece.w * 0.6} height="5" rx="2" style={{ fill: `color-mix(in srgb, ${piece.tone} 60%, var(--eq-ink))` }} />
          </svg>
        </GalleryItem>
      ))}
    </Gallery>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneGallery: names the list and the viewer, such as "Site photos".
layout"grid" | "masonry""grid"Gallery: uniform cells cropped to one ratio, or columns where each picture keeps its shape.
minWidthnumber160Gallery: narrowest a column may get, in pixels. Sets how many columns fit.
ratiostring"4 / 3"Gallery: cell shape for the grid layout, as a CSS aspect-ratio.
captions"below" | "overlay" | "hidden""below"Gallery: where captions sit in the grid. The viewer always shows them.
loopbooleanfalseGallery: in the viewer, next on the last picture goes to the first.
captionstringnoneGalleryItem: shown with the thumbnail and announced in the viewer.
childrenReactNodenoneGallery: GalleryItem elements. GalleryItem: an img with alt text or a labelled svg.
...restComponentProps<"div">noneGallery: every native div attribute.

Anatomy

root.eq-galleryHolds the list and the lightbox; data-layout and data-captions style it.
list.eq-gallery-listA labelled list, a grid or columns.
item.eq-gallery-item
button.eq-gallery-buttonOpens the viewer at this picture; one is in the tab order at a time.
frame.eq-gallery-frameCrops the picture to the cell in the grid layout.
caption.eq-gallery-caption

Keyboard

TabMoves into the gallery, to the last picture focused, and out again.
ArrowLeftArrowRightArrowUpArrowDownMoves to the nearest picture in that direction on screen.
HomeEndMoves to the first or last picture.
EnterSpaceOpens the viewer at that picture.
ArrowLeftArrowRightIn the viewer: shows the previous or next picture.
EscapeCloses the viewer and puts focus on the picture last shown.

Notes

  • Each thumbnail is a button with aria-haspopup="dialog" whose name comes from the picture's alt text and the caption.
  • The same element is shown as the thumbnail and full size, so give each img alt text or each svg role="img" and a label.
  • Use a srcset or a picture element so thumbnails load small and the viewer loads large.
  • Grid cells crop with object-fit: cover; for an svg, set preserveAspectRatio="xMidYMid slice" to crop the same way.
  • In the masonry layout pictures run down each column, so reading and tab order follow the columns rather than the rows.
  • Closing the viewer focuses the picture last shown, so a keyboard user keeps their place.
  • Where invoker commands are not native, the lightbox's own click listener opens the viewer the same way.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/gallery.tsx166 lines
"use client";

import {
  Children,
  isValidElement,
  useId,
  useRef,
  useState,
  type ComponentProps,
  type CSSProperties,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import { Lightbox, LightboxItem } from "./lightbox";
import "./gallery.css";

export type GalleryItemProps = {
  /** Shown under or over the thumbnail and in the viewer. */
  caption?: string;
  /** An img with alt text, or an svg with role="img" and a label. Shown as the thumbnail and full size in the viewer. */
  children: ReactNode;
};

/** One picture in a Gallery. It renders nothing by itself; the gallery lays it out. */
export function GalleryItem({ children }: GalleryItemProps) {
  return <>{children}</>;
}

export type GalleryProps = Omit<ComponentProps<"div">, "children"> & {
  /** Names the list and the viewer, e.g. "Site photos". */
  label: string;
  /** Uniform cells cropped to one ratio, or columns where each picture keeps its own shape. */
  layout?: "grid" | "masonry";
  /** Narrowest a column may get, in pixels. Sets how many columns fit. */
  minWidth?: number;
  /** Cell shape for the grid layout, as a CSS aspect-ratio. */
  ratio?: string;
  /** Where captions sit in the grid. They always show in the viewer. */
  captions?: "below" | "overlay" | "hidden";
  /** In the viewer, next on the last picture goes to the first. */
  loop?: boolean;
  /** GalleryItem elements. */
  children: ReactNode;
};

type Direction = "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown";

/** The nearest cell in a direction, by position on screen, so it works for both layouts. */
function nearest(cells: HTMLElement[], from: number, direction: Direction): number {
  const origin = cells[from]?.getBoundingClientRect();
  if (!origin) return from;
  const ox = origin.left + origin.width / 2;
  const oy = origin.top + origin.height / 2;
  let best = from;
  let score = Infinity;
  cells.forEach((cell, i) => {
    if (i === from) return;
    const box = cell.getBoundingClientRect();
    const dx = box.left + box.width / 2 - ox;
    const dy = box.top + box.height / 2 - oy;
    const along = { ArrowRight: dx, ArrowLeft: -dx, ArrowDown: dy, ArrowUp: -dy }[direction];
    const across = direction === "ArrowLeft" || direction === "ArrowRight" ? Math.abs(dy) : Math.abs(dx);
    if (along <= 1) return;
    const s = along + across * 3;
    if (s < score) {
      score = s;
      best = i;
    }
  });
  return best;
}

export function Gallery({
  label,
  layout = "grid",
  minWidth = 160,
  ratio = "4 / 3",
  captions = "below",
  loop = false,
  className,
  style,
  children,
  ...rest
}: GalleryProps) {
  const id = useId();
  const viewerId = `${id}-viewer`;
  const list = useRef<HTMLUListElement>(null);
  const [focus, setFocus] = useState(0);
  const [index, setIndex] = useState(0);
  const items = Children.toArray(children).filter(isValidElement<GalleryItemProps>);
  const active = Math.min(focus, Math.max(items.length - 1, 0));

  function cells() {
    return Array.from(list.current?.querySelectorAll<HTMLElement>(".eq-gallery-button") ?? []);
  }

  function move(to: number) {
    const all = cells();
    const cell = all[to];
    if (!cell) return;
    setFocus(to);
    cell.focus();
  }

  function handleKeyDown(event: KeyboardEvent<HTMLUListElement>) {
    if (event.altKey || event.ctrlKey || event.metaKey) return;
    const key = event.key;
    if (key === "ArrowLeft" || key === "ArrowRight" || key === "ArrowUp" || key === "ArrowDown") {
      move(nearest(cells(), active, key));
    } else if (key === "Home") move(0);
    else if (key === "End") move(items.length - 1);
    else return;
    event.preventDefault();
    event.stopPropagation();
  }

  return (
    <div
      {...rest}
      className={["eq-gallery", className].filter(Boolean).join(" ")}
      style={{ ...style, "--_min": `${minWidth}px`, "--_ratio": ratio } as CSSProperties}
      data-layout={layout}
      data-captions={captions}
    >
      <ul ref={list} className="eq-gallery-list" aria-label={label} onKeyDown={handleKeyDown}>
        {items.map((item, i) => (
          <li key={item.key ?? i} className="eq-gallery-item">
            <button
              type="button"
              className="eq-gallery-button"
              tabIndex={i === active ? 0 : -1}
              aria-haspopup="dialog"
              command="show-modal"
              commandfor={viewerId}
              onFocus={() => setFocus(i)}
              onClick={() => setIndex(i)}
            >
              <span className="eq-gallery-frame">{item.props.children}</span>
              {item.props.caption && captions !== "hidden" && (
                <span className="eq-gallery-caption">{item.props.caption}</span>
              )}
            </button>
          </li>
        ))}
      </ul>
      <Lightbox
        id={viewerId}
        label={label}
        index={index}
        onIndexChange={setIndex}
        loop={loop}
        onClose={() => {
          // Back on the picture last shown, not the one that opened the viewer.
          setFocus(index);
          cells()[index]?.focus();
        }}
      >
        {items.map((item, i) => (
          <LightboxItem key={item.key ?? i} caption={item.props.caption}>
            {item.props.children}
          </LightboxItem>
        ))}
      </Lightbox>
    </div>
  );
}
components/equip/gallery.css115 lines
@layer equip {
  .eq-gallery {
    min-width: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.35 var(--eq-font);
  }

  .eq-gallery-list {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(var(--_min), 100%), 1fr));
    gap: var(--eq-space-2);
    margin: 0;
    padding: 0;
    list-style: none;
  }

  /* Masonry: columns, so each picture keeps its own height. Pictures run down each column. */
  .eq-gallery[data-layout="masonry"] .eq-gallery-list {
    display: block;
    columns: var(--_min) auto;
    column-gap: var(--eq-space-2);
  }

  .eq-gallery[data-layout="masonry"] .eq-gallery-item {
    margin-bottom: var(--eq-space-2);
    break-inside: avoid;
  }

  .eq-gallery-item {
    min-width: 0;
  }

  .eq-gallery-button {
    position: relative;
    box-sizing: border-box;
    display: grid;
    gap: var(--eq-space-1);
    width: 100%;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: var(--eq-radius-m);
    background: transparent;
    color: inherit;
    font: inherit;
    text-align: start;
    cursor: zoom-in;
    transition: scale var(--eq-dur) var(--eq-ease);
  }

  .eq-gallery-button:active {
    scale: var(--eq-press);
  }

  .eq-gallery-button:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-gallery-frame {
    display: block;
    overflow: hidden;
    border-radius: var(--eq-radius-m);
    background: var(--eq-tint-strong);
    /* A hairline inside the edge, so pale pictures keep their shape on a pale page. */
    box-shadow: inset 0 0 0 1px var(--eq-line);
  }

  .eq-gallery[data-layout="grid"] .eq-gallery-frame {
    aspect-ratio: var(--_ratio);
  }

  .eq-gallery-frame > :is(img, svg, picture, video, canvas) {
    display: block;
    width: 100%;
    height: auto;
    transition: scale var(--eq-dur-move) var(--eq-spring);
  }

  .eq-gallery[data-layout="grid"] .eq-gallery-frame > :is(img, svg, picture, video, canvas) {
    height: 100%;
    object-fit: cover;
  }

  @media (hover: hover) {
    .eq-gallery-button:hover .eq-gallery-frame > :is(img, svg, picture, video, canvas) {
      scale: 1.04;
    }
  }

  .eq-gallery-caption {
    overflow: hidden;
    padding-inline: 2px;
    color: var(--eq-ink-2);
    font-size: var(--eq-text-s);
    white-space: nowrap;
    text-overflow: ellipsis;
  }

  .eq-gallery[data-captions="overlay"] .eq-gallery-caption {
    position: absolute;
    right: var(--eq-space-1);
    bottom: var(--eq-space-1);
    left: var(--eq-space-1);
    width: fit-content;
    max-width: calc(100% - 2 * var(--eq-space-1));
    box-sizing: border-box;
    padding: 2px var(--eq-space-2);
    border-radius: var(--eq-radius-s);
    background: color-mix(in srgb, var(--eq-solid) 80%, transparent);
    color: var(--eq-solid-ink);
    font-size: var(--eq-text-xs);
    font-weight: var(--eq-weight-strong);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index