Overlays
Lightbox
A full-screen viewer for a set of images or other visuals, with arrows, swipe, a counter and captions.
- Built on
<dialog>Invoker commandsPointer eventsContainer queries@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.9 kB
- Built from
- Button
Code
import { Button } from "@/components/equip/button";
import { Lightbox, LightboxItem } from "@/components/equip/lightbox";
/* Stand-in photos drawn from the tokens, so the demo needs no image files. */
const shots = [
{ caption: "Morning over the Tagus", sky: "var(--eq-focus)", sun: "var(--eq-warn)", land: "var(--eq-ok)" },
{ caption: "Tram 28 at Graça", sky: "var(--eq-warn)", sun: "var(--eq-danger)", land: "var(--eq-ink-2)" },
{ caption: "Tiles in Alfama", sky: "var(--eq-ok)", sun: "var(--eq-focus)", land: "var(--eq-danger)" },
{ caption: "Sunset from the miradouro", sky: "var(--eq-danger)", sun: "var(--eq-warn)", land: "var(--eq-ink)" },
];
function Shot({ sky, sun, land, label, size }: { sky: string; sun: string; land: string; label?: string; size: number }) {
return (
<svg
viewBox="0 0 120 80"
width={size * 1.5}
height={size}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
>
<rect width="120" height="80" style={{ fill: `color-mix(in srgb, ${sky} 35%, var(--eq-raised))` }} />
<circle cx="84" cy="28" r="12" style={{ fill: sun }} />
<path d="M0 62 Q30 40 60 58 T120 52 V80 H0Z" style={{ fill: `color-mix(in srgb, ${land} 70%, var(--eq-raised))` }} />
</svg>
);
}
export default function Demo() {
const [first] = shots;
return (
<>
<Button
variant="outline"
command="show-modal"
commandfor="demo-lightbox"
lead={first && <Shot {...first} size={16} />}
>
Lisbon, 4 photos
</Button>
<Lightbox id="demo-lightbox" label="Lisbon photos">
{shots.map((shot) => (
<LightboxItem key={shot.caption} caption={shot.caption}>
<Shot {...shot} label={shot.caption} size={640} />
</LightboxItem>
))}
</Lightbox>
</>
);
}Examples
Open at the tapped thumbnail
Each thumbnail sets the index, then opens the viewer with an invoker command. Closing returns focus to the thumbnail.
Code
"use client";
import { useState } from "react";
import { Lightbox, LightboxItem } from "@/components/equip/lightbox";
const plans = [
{ caption: "Ground floor, 84 m²", rooms: [[4, 4, 60, 40], [64, 4, 52, 40], [4, 44, 112, 32]] },
{ caption: "First floor, 71 m²", rooms: [[4, 4, 40, 72], [44, 4, 72, 36], [44, 40, 72, 36]] },
{ caption: "Attic, 38 m²", rooms: [[20, 20, 80, 56]] },
{ caption: "Garden plan", rooms: [[4, 4, 112, 20], [4, 24, 56, 52], [60, 24, 56, 52]] },
];
function Plan({ rooms, label, size }: { rooms: number[][]; label?: string; size: number }) {
return (
<svg
viewBox="0 0 120 80"
width={size * 1.5}
height={size}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
style={{ background: "var(--eq-raised)" }}
>
{rooms.map(([x, y, w, h]) => (
<rect
key={`${x}-${y}`}
x={x}
y={y}
width={w}
height={h}
style={{ fill: "var(--eq-tint)", stroke: "var(--eq-ink-2)", strokeWidth: 1.5 }}
/>
))}
</svg>
);
}
export default function Example() {
const [index, setIndex] = useState(0);
return (
<>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{plans.map((plan, i) => (
<button
key={plan.caption}
type="button"
command="show-modal"
commandfor="example-floor-plans"
aria-label={`Open ${plan.caption}`}
onClick={() => setIndex(i)}
style={{
padding: 0,
border: "1px solid var(--eq-line-strong)",
borderRadius: "var(--eq-radius-m)",
overflow: "hidden",
cursor: "zoom-in",
}}
>
<Plan rooms={plan.rooms} size={48} />
</button>
))}
</div>
<Lightbox id="example-floor-plans" label="Floor plans" index={index} onIndexChange={setIndex}>
{plans.map((plan) => (
<LightboxItem key={plan.caption} caption={plan.caption}>
<Plan rooms={plan.rooms} label={plan.caption} size={600} />
</LightboxItem>
))}
</Lightbox>
</>
);
}Any content, looping
Items need not be images. With loop, Next on the last swatch goes back to the first.
Code
import { Button } from "@/components/equip/button";
import { Lightbox, LightboxItem } from "@/components/equip/lightbox";
const swatches = [
{ name: "Ink", use: "body text and icons", value: "var(--eq-ink)" },
{ name: "Focus", use: "focus rings", value: "var(--eq-focus)" },
{ name: "Positive", use: "success states", value: "var(--eq-ok)" },
{ name: "Warning", use: "warnings", value: "var(--eq-warn)" },
{ name: "Danger", use: "destructive actions", value: "var(--eq-danger)" },
];
export default function Example() {
return (
<>
<Button variant="outline" command="show-modal" commandfor="example-swatches">
Review palette
</Button>
<Lightbox id="example-swatches" label="Palette" loop>
{swatches.map((swatch) => (
<LightboxItem key={swatch.name} caption={`${swatch.name}, for ${swatch.use}`}>
<div
style={{
display: "grid",
alignContent: "end",
width: "min(70cqw, 420px)",
aspectRatio: "4 / 3",
padding: 16,
boxSizing: "border-box",
borderRadius: "var(--eq-radius-l)",
background: swatch.value,
color: "var(--eq-solid-ink)",
fontWeight: "var(--eq-weight-strong)",
}}
>
{swatch.name}
</div>
</LightboxItem>
))}
</Lightbox>
</>
);
}A single item
With one item there is nothing to step through, so the arrows and the counter are left out and only the caption shows.
Code
import { Button } from "@/components/equip/button";
import { Lightbox, LightboxItem } from "@/components/equip/lightbox";
export default function Example() {
return (
<>
<Button variant="outline" command="show-modal" commandfor="example-floor-plan">
View floor plan
</Button>
<Lightbox id="example-floor-plan" label="Floor plan">
<LightboxItem caption="Level 2, east wing">
<svg
role="img"
aria-label="Floor plan of level 2, east wing: two meeting rooms beside an open office"
viewBox="0 0 320 200"
style={{ width: "min(80cqw, 560px)", height: "auto", background: "var(--eq-raised)", borderRadius: "var(--eq-radius-m)" }}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="16" y="16" width="288" height="168" />
<path d="M120 16v72h-24M120 120v64M120 88h-104M200 16v168M200 100h104" />
<text x="40" y="56" fill="currentColor" stroke="none" fontSize="12">Room A</text>
<text x="40" y="148" fill="currentColor" stroke="none" fontSize="12">Room B</text>
<text x="127" y="104" fill="currentColor" stroke="none" fontSize="12">Open office</text>
<text x="224" y="60" fill="currentColor" stroke="none" fontSize="12">Kitchen</text>
<text x="224" y="148" fill="currentColor" stroke="none" fontSize="12">Stairs</text>
</svg>
</LightboxItem>
</Lightbox>
</>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
id | string | none | Lightbox: required. Openers point at it with command="show-modal" and commandfor. |
label | string | none | Lightbox: names the viewer, such as "Site photos". |
index | number | none | Lightbox: the item on screen, when you control it. |
defaultIndex | number | 0 | Lightbox: the item shown first. |
onIndexChange | (index: number) => void | none | Lightbox: called with the new index. |
loop | boolean | false | Lightbox: next on the last item goes to the first, and back the other way. |
caption | string | none | LightboxItem: shown under the counter and announced with it while this item is on screen. |
children | ReactNode | none | Lightbox: LightboxItem elements. LightboxItem: an img, svg or any element. |
...rest | ComponentProps<"dialog"> | ComponentProps<"figure"> | none | Every native attribute: dialog for Lightbox, figure for LightboxItem. |
Anatomy
| root | .eq-lightbox | A modal dialog that fills the viewport. |
|---|---|---|
| close | .eq-lightbox-close | The chip holding the close button. |
| stage | .eq-lightbox-stage | Where items stack and where swipes are read. |
| slide | .eq-lightbox-slide | Wraps each item; inert unless it is the current one. |
| item | .eq-lightbox-item | LightboxItem, a figure. Media inside fits the stage. |
| bar | .eq-lightbox-bar | Previous, the status and next. With one item, only the caption, and data-single. |
| status | .eq-lightbox-status | A polite live region with the counter and caption. |
| count | .eq-lightbox-count | "2 of 5". |
| caption | .eq-lightbox-caption |
Keyboard
| ArrowLeftArrowRight | Shows the previous or next item. |
|---|---|
| HomeEnd | Shows the first or last item. |
| Escape | Closes the viewer and returns focus to the button that opened it. |
| Tab | Moves between close, previous and next; focus stays inside the open viewer. |
Notes
- Open it with a button carrying command="show-modal" and commandfor set to the id; to open at a given item, set index in that button's click handler.
- It is a native modal dialog, so the page behind is inert and focus returns to the opener on close.
- The counter and caption sit in a polite live region, so each change is announced.
- Swipe left or right on touch to move between items; a tap on the content never closes it.
- A click anywhere outside the item and the controls closes it, like a backdrop.
- Give every image alt text, or an svg role="img" and a label; the caption is not a replacement.
- With a single item there is nothing to step through: no previous, next or counter, and no bar at all unless the item has a caption.
- Previous and next stay focusable at the ends and use aria-disabled, so focus never drops out of the dialog.
- The control names (Close, Previous, Next) and the counter text are English; copy the part and change them to translate.
- The page behind can still scroll on some engines; add overflow: hidden to the root while it is open if that matters.
- Where invoker commands are not native, a click listener opens and closes the viewer the same way. The exit fade needs transition of overlay, which only Chromium has; elsewhere the viewer fades in but closes at once.
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/lightbox.tsx248 lines
"use client";
import {
Children,
isValidElement,
useEffect,
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
type ReactNode,
} from "react";
import { Button } from "./button";
import "./lightbox.css";
// React 19 does not type invoker commands yet. This lets any button take them.
declare module "react" {
interface ButtonHTMLAttributes<T> {
command?: string;
commandfor?: string;
}
}
export type LightboxItemProps = ComponentProps<"figure"> & {
/** Shown and announced with the counter while this item is on screen. */
caption?: string;
/** An img, an svg or any element. Images keep their aspect and fit the viewport. */
children: ReactNode;
};
export function LightboxItem({ caption, className, children, ...rest }: LightboxItemProps) {
return (
<figure aria-label={caption} {...rest} className={["eq-lightbox-item", className].filter(Boolean).join(" ")}>
{children}
</figure>
);
}
export type LightboxProps = Omit<ComponentProps<"dialog">, "children"> & {
/** Required. Openers point at it with command="show-modal" and commandfor. */
id: string;
/** Names the viewer, e.g. "Site photos". */
label: string;
/** The item on screen, when you control it. */
index?: number;
/** The item shown first. */
defaultIndex?: number;
onIndexChange?: (index: number) => void;
/** Next on the last item goes back to the first, and the other way round. */
loop?: boolean;
/** LightboxItem elements. */
children: ReactNode;
};
const SWIPE = 48;
function Icon({ d }: { d: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={d} />
</svg>
);
}
export function Lightbox({
id,
label,
index,
defaultIndex = 0,
onIndexChange,
loop = false,
className,
onClick,
onKeyDown,
onPointerDown,
children,
...rest
}: LightboxProps) {
const dialog = useRef<HTMLDialogElement>(null);
const stage = useRef<HTMLDivElement>(null);
const swipe = useRef<{ x: number; moved: boolean } | null>(null);
const swallowClick = useRef(false);
const [inner, setInner] = useState(defaultIndex);
const items = Children.toArray(children).filter(isValidElement<LightboxItemProps>);
const count = items.length;
const current = Math.min(Math.max(index ?? inner, 0), Math.max(count - 1, 0));
const atStart = !loop && current === 0;
const atEnd = !loop && current === count - 1;
const caption = items[current]?.props.caption;
const single = count === 1;
// Invoker commands where the engine lacks them, as Dialog does.
useEffect(() => {
if ("command" in HTMLButtonElement.prototype) return;
function invoke(event: globalThis.MouseEvent) {
if (!(event.target instanceof Element)) return;
const invoker = event.target.closest("button[commandfor]");
if (!invoker || invoker.getAttribute("commandfor") !== id) return;
const element = document.getElementById(id);
if (!(element instanceof HTMLDialogElement)) return;
const command = invoker.getAttribute("command");
if (command === "show-modal" && !element.open) element.showModal();
if ((command === "close" || command === "request-close") && element.open) element.close();
}
document.addEventListener("click", invoke);
return () => document.removeEventListener("click", invoke);
}, [id]);
function go(to: number) {
if (count === 0) return;
const next = loop ? (to + count) % count : Math.min(Math.max(to, 0), count - 1);
if (next === current) return;
if (index === undefined) setInner(next);
onIndexChange?.(next);
}
function handleKeyDown(event: KeyboardEvent<HTMLDialogElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || event.altKey || event.metaKey || event.ctrlKey) return;
if (event.key === "ArrowLeft") go(current - 1);
else if (event.key === "ArrowRight") go(current + 1);
else if (event.key === "Home") go(0);
else if (event.key === "End") go(count - 1);
else return;
event.preventDefault();
}
// Anything that is not the content or a control counts as the backdrop.
function handleClick(event: MouseEvent<HTMLDialogElement>) {
onClick?.(event);
if (swallowClick.current) {
swallowClick.current = false;
return;
}
if (event.defaultPrevented || !(event.target instanceof Element)) return;
if (event.target.closest(".eq-lightbox-item, .eq-lightbox-bar, .eq-lightbox-close")) return;
dialog.current?.close();
}
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
if (event.pointerType === "mouse" || count < 2) return;
swipe.current = { x: event.clientX, moved: false };
}
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
const start = swipe.current;
if (!start) return;
const dx = event.clientX - start.x;
// Capture only once it is clearly a swipe, so a tap still lands on what was tapped.
if (!start.moved && Math.abs(dx) > 8) {
start.moved = true;
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.setAttribute("data-swiping", "");
}
if (start.moved) event.currentTarget.style.setProperty("--_drag", `${dx}px`);
}
function endSwipe(event: PointerEvent<HTMLDivElement>, commit: boolean) {
const start = swipe.current;
swipe.current = null;
const node = stage.current;
if (!start?.moved || !node) return;
node.removeAttribute("data-swiping");
node.style.removeProperty("--_drag");
swallowClick.current = true;
const dx = event.clientX - start.x;
if (commit && Math.abs(dx) > SWIPE) go(current + (dx < 0 ? 1 : -1));
}
return (
<dialog
ref={dialog}
aria-label={label}
{...rest}
id={id}
className={["eq-lightbox", className].filter(Boolean).join(" ")}
onClick={handleClick}
onKeyDown={handleKeyDown}
onPointerDown={(event) => {
onPointerDown?.(event);
swallowClick.current = false;
}}
>
<div className="eq-lightbox-close">
<Button
variant="ghost"
aria-label="Close"
onClick={() => dialog.current?.close()}
lead={<Icon d="M4 4l8 8M12 4l-8 8" />}
/>
</div>
<div
ref={stage}
className="eq-lightbox-stage"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={(event) => endSwipe(event, true)}
onPointerCancel={(event) => endSwipe(event, false)}
>
{items.map((item, i) => (
<div
key={item.key ?? i}
className="eq-lightbox-slide"
data-position={i < current ? "before" : i > current ? "after" : "current"}
inert={i !== current}
>
{item}
</div>
))}
</div>
{single ? (
// One item: nothing to step through, so no arrows and no counter, and no bar without a caption.
caption && (
<div className="eq-lightbox-bar" data-single="">
<p className="eq-lightbox-status" aria-live="polite" aria-atomic="true">
<span className="eq-lightbox-caption">{caption}</span>
</p>
</div>
)
) : (
<div className="eq-lightbox-bar">
<Button
variant="ghost"
aria-label="Previous"
aria-disabled={atStart || undefined}
onClick={() => go(current - 1)}
lead={<Icon d="M10 3.5 5.5 8l4.5 4.5" />}
/>
<p className="eq-lightbox-status" aria-live="polite" aria-atomic="true">
<span className="eq-lightbox-count">
{count === 0 ? "Nothing to show" : `${current + 1} of ${count}`}
</span>
{caption && <span className="eq-lightbox-caption">{caption}</span>}
</p>
<Button
variant="ghost"
aria-label="Next"
aria-disabled={atEnd || undefined}
onClick={() => go(current + 1)}
lead={<Icon d="M6 3.5 10.5 8 6 12.5" />}
/>
</div>
)}
</dialog>
);
}components/equip/lightbox.css193 lines
@layer equip {
.eq-lightbox {
box-sizing: border-box;
position: fixed;
inset: 0;
width: 100%;
height: 100%;
max-width: none;
max-height: none;
margin: 0;
padding: var(--eq-space-4);
border: 0;
background: color-mix(in srgb, var(--eq-bg) 90%, transparent);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
overflow: hidden;
opacity: 0;
scale: 0.98;
transition: opacity var(--eq-dur) var(--eq-ease), scale var(--eq-dur) var(--eq-ease);
}
.eq-lightbox[open] {
display: grid;
grid-template: "close" auto "stage" minmax(0, 1fr) "bar" auto / minmax(0, 1fr);
gap: var(--eq-space-3);
opacity: 1;
scale: none;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-lightbox[open] {
opacity: 0;
scale: 0.98;
}
}
.eq-lightbox::backdrop {
background: var(--eq-backdrop);
opacity: 0;
transition: opacity var(--eq-dur) var(--eq-ease);
}
.eq-lightbox[open]::backdrop {
opacity: 1;
}
@starting-style {
.eq-lightbox[open]::backdrop {
opacity: 0;
}
}
/* The exit fade needs the dialog kept in the top layer while it runs. */
@supports (overlay: auto) {
.eq-lightbox {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-lightbox[open] {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-lightbox::backdrop {
transition:
opacity var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
}
.eq-lightbox-close {
grid-area: close;
justify-self: end;
}
.eq-lightbox-close,
.eq-lightbox-bar {
display: flex;
align-items: center;
max-width: 100%;
padding: var(--eq-space-1);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
}
.eq-lightbox-stage {
grid-area: stage;
display: grid;
min-height: 0;
touch-action: pan-y pinch-zoom;
}
.eq-lightbox-slide {
grid-area: 1 / 1;
display: grid;
place-items: center;
min-width: 0;
min-height: 0;
container-type: size;
--_shift: 0px;
translate: calc(var(--_shift) + var(--_drag, 0px)) 0;
transition:
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur-move) var(--eq-spring),
visibility var(--eq-dur) allow-discrete;
}
.eq-lightbox-slide[data-position="before"] {
--_shift: -16px;
}
.eq-lightbox-slide[data-position="after"] {
--_shift: 16px;
}
.eq-lightbox-slide:not([data-position="current"]) {
opacity: 0;
visibility: hidden;
}
/* Under a finger the slide follows exactly, then springs on release. */
.eq-lightbox-stage[data-swiping] .eq-lightbox-slide {
transition: none;
}
.eq-lightbox-item {
margin: 0;
display: grid;
place-items: center;
max-width: 100cqw;
max-height: 100cqh;
}
.eq-lightbox-item > :is(img, svg, video, canvas, picture) {
display: block;
max-width: 100cqw;
max-height: 100cqh;
width: auto;
height: auto;
object-fit: contain;
border-radius: var(--eq-radius-m);
}
.eq-lightbox-bar {
grid-area: bar;
justify-self: center;
gap: var(--eq-space-2);
}
/* One item: the bar only carries the caption, so it takes a little height of its own. */
.eq-lightbox-bar[data-single] {
padding: var(--eq-space-2) var(--eq-space-1);
}
.eq-lightbox-status {
display: grid;
min-width: 0;
margin: 0;
padding-inline: var(--eq-space-2);
text-align: center;
}
.eq-lightbox-count {
font-size: var(--eq-text-s);
font-weight: var(--eq-weight-strong);
font-variant-numeric: tabular-nums;
}
.eq-lightbox-caption {
overflow: hidden;
font-size: var(--eq-text-s);
color: var(--eq-ink-2);
white-space: nowrap;
text-overflow: ellipsis;
}
}