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
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Gallery: 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. |
minWidth | number | 160 | Gallery: narrowest a column may get, in pixels. Sets how many columns fit. |
ratio | string | "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. |
loop | boolean | false | Gallery: in the viewer, next on the last picture goes to the first. |
caption | string | none | GalleryItem: shown with the thumbnail and announced in the viewer. |
children | ReactNode | none | Gallery: GalleryItem elements. GalleryItem: an img with alt text or a labelled svg. |
...rest | ComponentProps<"div"> | none | Gallery: every native div attribute. |
Anatomy
| root | .eq-gallery | Holds the list and the lightbox; data-layout and data-captions style it. |
|---|---|---|
| list | .eq-gallery-list | A labelled list, a grid or columns. |
| item | .eq-gallery-item | |
| button | .eq-gallery-button | Opens the viewer at this picture; one is in the tab order at a time. |
| frame | .eq-gallery-frame | Crops the picture to the cell in the grid layout. |
| caption | .eq-gallery-caption |
Keyboard
| Tab | Moves into the gallery, to the last picture focused, and out again. |
|---|---|
| ArrowLeftArrowRightArrowUpArrowDown | Moves to the nearest picture in that direction on screen. |
| HomeEnd | Moves to the first or last picture. |
| EnterSpace | Opens the viewer at that picture. |
| ArrowLeftArrowRight | In the viewer: shows the previous or next picture. |
| Escape | Closes 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);
}
}