Display
Image
An image in a fixed-ratio frame, with a loading skeleton, a fallback when it fails, a caption and zoom.
- Built on
<img><figure>aspect-ratioobject-fit:has()<dialog>Invoker commands- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.8 kB
Code
import { Image } from "@/components/equip/image";
/* A stand-in photo as an inline SVG data URI, so the demo makes no request. */
const harbour =
"data:image/svg+xml," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 160 90">
<rect width="160" height="90" fill="#7d9bb4"/>
<circle cx="118" cy="30" r="11" fill="#d9a55b"/>
<path d="M0 58 L34 36 L58 52 L86 30 L122 56 L160 44 V90 H0Z" fill="#5f7a68"/>
<path d="M0 70 Q40 62 80 70 T160 68 V90 H0Z" fill="#4b6377"/>
</svg>`,
);
export default function Demo() {
return <Image src={harbour} alt="Hills above the harbour at Porto, late afternoon" caption="Porto, from the Gaia side" style={{ width: 168 }} />;
}Examples
Click to zoom
zoom turns the frame into a button that opens the image in a Lightbox, with Escape and a backdrop click to close.
Code
import { Image } from "@/components/equip/image";
const ridge =
"data:image/svg+xml," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1200" viewBox="0 0 160 120">
<rect width="160" height="120" fill="#8a9fb8"/>
<circle cx="42" cy="36" r="12" fill="#e0b46a"/>
<path d="M0 80 L40 48 L70 70 L104 38 L160 78 V120 H0Z" fill="#6a7f6c"/>
<path d="M0 96 Q50 84 100 96 T160 92 V120 H0Z" fill="#53697c"/>
</svg>`,
);
export default function Example() {
return (
<Image
src={ridge}
alt="Pine ridge in the Serra da Estrela with the sun low on the left"
ratio="4 / 3"
caption="Serra da Estrela, October"
zoom
style={{ width: "100%", maxWidth: 280 }}
/>
);
}Loading and failed
Without a src the frame stays a skeleton; a source that fails shows the fallback, which still carries the alt text for screen readers.
Code
import { Image } from "@/components/equip/image";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 16, width: "100%", maxWidth: 360 }}>
<Image style={{ flex: "1 1 140px", minWidth: 0 }} alt="Floor plan for unit 4B" ratio="1 / 1" caption="Loading" />
<Image style={{ flex: "1 1 140px", minWidth: 0 }} src="data:image/png;base64,broken" alt="Floor plan for unit 4C" ratio="1 / 1" caption="Failed to load" />
</div>
);
}Cover and contain
Both frames are 1 / 1. Cover crops the wide picture to fill; contain shows all of it on a tinted letterbox.
Code
import { Image } from "@/components/equip/image";
const coast =
"data:image/svg+xml," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="1800" height="900" viewBox="0 0 180 90">
<rect width="180" height="90" fill="#86a3b0"/>
<circle cx="140" cy="26" r="10" fill="#d8a85e"/>
<path d="M0 52 L50 40 L90 56 L130 44 L180 52 V90 H0Z" fill="#647d70"/>
<path d="M0 68 Q45 60 90 68 T180 66 V90 H0Z" fill="#4e6679"/>
</svg>`,
);
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 16, width: "100%", maxWidth: 320 }}>
<Image style={{ flex: "1 1 120px", minWidth: 0 }} src={coast} alt="Coastline near Sagres" ratio="1 / 1" fit="cover" caption="Cover" />
<Image style={{ flex: "1 1 120px", minWidth: 0 }} src={coast} alt="Coastline near Sagres" ratio="1 / 1" fit="contain" caption="Contain" />
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
alt | string | none | Required. Describes the picture; "" only for a decorative image that does not zoom. |
src | string | none | The image. Left out, the frame stays a skeleton until one arrives. |
ratio | string | "16 / 9" | The frame's aspect ratio as CSS takes it, such as "1 / 1". |
fit | "cover" | "contain" | "cover" | Cover fills the frame and crops; contain shows all of it on a tinted letterbox. |
caption | ReactNode | none | Shown under the image. With one, the root is a figure; a string is also shown in the zoom viewer. |
zoom | boolean | false | Makes the image a button that opens it full screen in a Lightbox. |
fallbackText | string | "Image unavailable" | Shown in the frame when the image fails. |
className | string | none | Added to the root, so it sizes and places the whole part. |
style | CSSProperties | none | Applied to the root, so a width sizes the whole part. |
...rest | ComponentProps<"img"> | none | Every other native img attribute, such as loading, sizes, srcSet, onLoad and ref, goes on the img itself. |
Anatomy
| root | .eq-image | A figure with a caption, else a div. data-status is loading, loaded or error. |
|---|---|---|
| frame | .eq-image-frame | Holds the ratio and clips; a shimmering skeleton while loading. |
| img | .eq-image-img | |
| zoom | .eq-image-zoom | With zoom, the button around the img that opens the viewer. |
| zoom hint | .eq-image-zoom-hint | A small magnifier chip, shown on hover and focus. |
| fallback | .eq-image-fallback | Replaces the img when it fails; role="img" named by the alt text. |
| fallback text | .eq-image-fallback-text | |
| caption | .eq-image-caption | A figcaption. |
| viewer | .eq-image-viewer | The Lightbox. With its one image it shows no counter or arrows. |
| large image | .eq-image-large | The img inside the viewer. |
Keyboard
| EnterSpace | With zoom, opens the image full screen. |
|---|---|
| Escape | Closes the viewer and returns focus to the image. |
Notes
- An image that loads or fails before React hydrates never fires onLoad or onError, so on mount the part reads complete and naturalWidth from the element and settles its state.
- The skeleton sits behind the img rather than hiding it, so the server-rendered page shows a picture that is already there.
- When loading fails the img is removed and the fallback is a role="img" named by the alt text plus the fallback text, so the description is not lost.
- The zoom button is named by the alt text with ", view larger" added, and says it opens a dialog; the words are English, copy the part to translate them.
- Zoom composes Lightbox and hides its counter and arrows with CSS, since one image has nothing to step through; the bar shows only for a string caption.
- The shimmer stops under reduced motion, as Skeleton's does.
- The focus ring and press on the zoom frame use :has(); without it the ring is not drawn. Where invoker commands are not native, Lightbox opens the viewer with a click listener.
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/image.tsx127 lines
"use client";
import { useEffect, useId, useRef, useState, type ComponentProps, type CSSProperties, type ReactNode } from "react";
import { Icon } from "./icon";
import { Lightbox, LightboxItem } from "./lightbox";
import "./image.css";
export type ImageProps = Omit<ComponentProps<"img">, "children"> & {
/** Required. Describes the picture; pass "" only for a purely decorative image that cannot zoom. */
alt: string;
/** The frame's aspect ratio, as CSS takes it: "16 / 9", "1 / 1", "4 / 3". */
ratio?: string;
/** cover fills the frame and crops; contain shows the whole image inside it. */
fit?: "cover" | "contain";
/** A caption under the image. With one, the root is a figure. */
caption?: ReactNode;
/** Makes the image a button that opens it full screen in a Lightbox. */
zoom?: boolean;
/** Shown in the frame when the image fails to load. */
fallbackText?: string;
/** Added to the root frame, so it sizes and places the whole part. */
className?: string;
/** Applied to the root frame, so width and margins size the whole part. */
style?: CSSProperties;
};
type Status = "loading" | "loaded" | "error";
export function Image({
alt,
src,
ratio = "16 / 9",
fit = "cover",
caption,
zoom = false,
fallbackText = "Image unavailable",
className,
style,
onLoad,
onError,
...rest
}: ImageProps) {
const img = useRef<HTMLImageElement>(null);
const viewer = useId();
// Tagged with the src it belongs to, so a new src starts loading again without an effect.
const [settled, setSettled] = useState<{ src: unknown; status: Status } | null>(null);
const status: Status = settled && settled.src === src ? settled.status : "loading";
// The image can finish, or fail, before React hydrates, and then onLoad and onError never
// fire. Read the element itself once it is in the page.
useEffect(() => {
const node = img.current;
// No src yet: stay a skeleton until one arrives.
if (!node || !src || !node.complete) return;
setSettled({ src, status: node.naturalWidth > 0 ? "loaded" : "error" });
}, [src]);
const failed = status === "error";
const canZoom = zoom && !failed && typeof src === "string" && src !== "";
const Root = caption != null ? "figure" : "div";
const picture = (
<img
ref={img}
alt={alt}
src={src}
decoding="async"
{...rest}
className="eq-image-img"
onLoad={(event) => {
setSettled({ src, status: "loaded" });
onLoad?.(event);
}}
onError={(event) => {
setSettled({ src, status: "error" });
onError?.(event);
}}
/>
);
return (
<Root
className={["eq-image", className].filter(Boolean).join(" ")}
data-status={status}
data-fit={fit}
style={style}
>
<div className="eq-image-frame" style={{ aspectRatio: ratio }}>
{canZoom ? (
<button
type="button"
className="eq-image-zoom"
command="show-modal"
commandfor={viewer}
aria-haspopup="dialog"
aria-label={`${alt}, view larger`}
>
{picture}
<span className="eq-image-zoom-hint" aria-hidden="true">
<Icon name="zoom-in" size="s" />
</span>
</button>
) : (
picture
)}
{failed && (
<div className="eq-image-fallback" role={alt ? "img" : undefined} aria-label={alt ? `${alt}. ${fallbackText}` : undefined} aria-hidden={alt ? undefined : true}>
<Icon name="image" size="m" />
<span className="eq-image-fallback-text">{fallbackText}</span>
</div>
)}
</div>
{caption != null && <figcaption className="eq-image-caption">{caption}</figcaption>}
{canZoom && (
<Lightbox
id={viewer}
label={alt || "Image"}
className="eq-image-viewer"
>
<LightboxItem caption={typeof caption === "string" ? caption : undefined}>
<img className="eq-image-large" src={src} alt={alt} />
</LightboxItem>
</Lightbox>
)}
</Root>
);
}components/equip/image.css160 lines
@layer equip {
.eq-image {
display: block;
min-width: 0;
max-width: 100%;
margin: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-image-frame {
position: relative;
display: block;
width: 100%;
overflow: hidden;
border-radius: var(--eq-radius-m);
/* Resting fill once the image is in: shows through a contained image's letterbox. */
background: var(--eq-tint);
transition:
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
/* While loading the frame is a skeleton block: the same sweep, behind the image, so the
server-rendered first frame never hides a picture that is already there. */
.eq-image[data-status="loading"] .eq-image-frame {
background: linear-gradient(90deg, transparent 33%, var(--eq-tint) 50%, transparent 67%)
var(--eq-tint-strong);
background-size: 300% 100%;
/* The one motion that runs on its own: a slow sweep that says "still loading". */
animation: eq-image-shimmer 2s ease-in-out infinite;
}
@keyframes eq-image-shimmer {
from {
background-position: 100% 0;
}
to {
background-position: 0 0;
}
}
@media (prefers-reduced-motion: reduce) {
.eq-image[data-status="loading"] .eq-image-frame {
animation: none;
}
}
.eq-image[data-fit="cover"][data-status="loaded"] .eq-image-frame {
background: transparent;
}
/* Pinned to the frame, so the ratio alone sets the size, with or without the zoom button. */
.eq-image-img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* An img without a picture yet would print its alt text over the skeleton. */
.eq-image[data-status="loading"] .eq-image-img {
color: transparent;
}
/* With no src at all the browser would draw its broken-image glyph over the skeleton. */
.eq-image-img:not([src]),
.eq-image-img[src=""] {
visibility: hidden;
}
.eq-image[data-fit="contain"] .eq-image-img {
object-fit: contain;
}
.eq-image[data-status="error"] .eq-image-img {
display: none;
}
.eq-image-fallback {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: var(--eq-space-1);
padding: var(--eq-space-2);
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
text-align: center;
overflow-wrap: anywhere;
}
.eq-image-zoom {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: none;
color: inherit;
cursor: zoom-in;
}
/* The frame clips, so the ring and the give are drawn on the frame itself. */
.eq-image-zoom:focus-visible {
outline: none;
}
.eq-image-frame:has(.eq-image-zoom:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-image-frame:has(.eq-image-zoom:active) {
scale: var(--eq-press);
}
.eq-image-zoom-hint {
position: absolute;
inset-block-end: var(--eq-space-2);
inset-inline-end: var(--eq-space-2);
display: grid;
place-items: center;
width: 24px;
height: 24px;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-s);
background: var(--eq-raised);
color: var(--eq-ink);
transition: opacity var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-image-zoom-hint {
opacity: 0;
}
.eq-image-zoom:hover .eq-image-zoom-hint {
opacity: 1;
}
}
.eq-image-zoom:focus-visible .eq-image-zoom-hint {
opacity: 1;
}
.eq-image-caption {
margin-top: var(--eq-space-2);
color: var(--eq-ink-2);
overflow-wrap: anywhere;
}
}