Image
Aspect-ratio boxes, srcset, lazy loading, placeholders, and alt text that earns the space it takes up.
Also called Picture, Figure, Thumbnail — in this system all of them are Image.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Reserve the space first
The left column keeps its shape from the first frame. The right one snaps into place when the image lands, pushing everything below it down.
Cover or contain
Cover crops to fill and is right for photography. Contain fits the whole image and is the only correct choice for a diagram or a logo, where a cropped edge changes the meaning.
Loading and failure
A skeleton at the correct ratio while loading, and a labelled placeholder on failure. The browser’s broken-image icon is never an acceptable outcome.
Figure and caption
A caption is content everyone reads; alt text is for people who cannot see the image. They are different jobs and should say different things.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
A ratio-locked box holding the image, with a caption beneath it as a sibling rather than an overlay.
- Ratio boxaspect-ratio on the wrapper
Held before anything loads. This one property removes most of the layout shift a page suffers.
- Object fitcover or contain
Never absent. Without it, any image whose intrinsic ratio differs from its box is squashed rather than cropped.
- Background--ds-surface-inset
Visible while loading, behind a transparent PNG, and around a contained image. A transparent box shows the page through the gaps.
- Radius12px, clipped
On the wrapper with overflow hidden, so the image is clipped rather than relying on the image having its own rounded corners.
- Loading stateSkeleton at the same ratio
Occupying the exact final dimensions, so the transition to the loaded image moves nothing.
- Failure stateIcon + one line
Explicit, at the same size. The browser’s broken-image icon looks like the product is broken rather than the asset.
- Caption12px, 8px below
A figcaption sibling. Overlaying it on the image makes contrast dependent on the picture, which changes every time the asset does.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-inset | — | The box behind the image |
| --ds-border-subtle | — | Optional edge, needed on images that reach the background colour |
| --ds-fg-muted | — | Caption and the failure message |
| --ds-layer-active | — | Skeleton fill while loading |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-2 | Gap from image to caption |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Standalone images and cards | |
| --radius-md | Thumbnails in lists |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-caption | Caption |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-normal | Fade from placeholder to loaded |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Radius | Min width | Max width | When to use |
|---|---|---|---|---|---|
| Thumbnail | 40–64px | 8px | — | — | In a list row or a table cell. Square, so a mixed set of source ratios stays aligned. |
| Card media | 16:9 or 4:3 | 12px top corners | — | — | Full-bleed to the card edge, with the radius only on the corners it touches. |
| Inline content | — | 12px | — | 40rem | Matched to the prose measure so it does not break the reading column. |
| Full-bleed | — | 0 | 100% | — | A hero or a section break, edge to edge with no radius. |
| Ratios | 16:9, 4:3, 1:1, 3:4 | — | — | — | A small set, used consistently. Arbitrary ratios make a grid impossible to align. |
<img width={1600} height={900} />
or: aspect-ratio: 16 / 9<img src="hero-final-v3-2.png" />
→ “hero dash final dash v three dash two dot png”Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Text over an image needs a scrim. Contrast against a photograph is not assertable and changes with every asset swap.
- An image that carries meaning through colour alone — a red status diagram — needs that meaning in the alt text too.
- A white product photo on a white surface needs a border, or the boundaries of the image are invisible.
- Avoid images of text entirely. They cannot be resized, translated or read out, which is exactly what 1.4.5 is about.
Keyboard
| Tab | Nothing — an image is not interactive unless it is inside a link or a button. |
| Enter | Opens a zoomable image in a lightbox, when the wrapper is a real button. |
| Esc | Closes the lightbox and returns focus to the thumbnail. |
Screen readers
- Do not begin alt text with "image of" or "picture of". The role is already announced.
- A decorative image with alt="" is skipped entirely, which is the correct outcome. A missing alt attribute is announced, often as the filename.
- For a complex diagram, provide a full description in the page and point at it with aria-describedby. Everyone benefits from that description, not only screen-reader users.
Focus & touch
- An image is never focusable on its own. When it opens a lightbox, the wrapper is a real button carrying the accessible name and the focus ring — never a click handler on the img element.
- Serve responsive sources — a 2400px hero on a 375px screen is several megabytes wasted on the connection least able to afford it. Pinch-to-zoom must never be disabled; images of dense content are exactly why people zoom. A zoomable image needs a 44px trigger, and the lightbox needs an obvious close control rather than relying on a swipe.
| Attribute | Applied to | Notes |
|---|---|---|
| alt | Every image | Required. Describes what the image conveys, not what it depicts. |
| alt="" | Decorative images | A deliberate empty string. It removes the image from the accessibility tree; a missing alt does not. |
| <figure> / <figcaption> | Captioned images | The caption is content everyone reads. It does not replace alt text, and it should not repeat it. |
| role="img" | A CSS or SVG image | With aria-label, when the picture is content rather than background. |
| aria-describedby | A complex diagram | Pointing at a longer description in the page. Alt text is one sentence; an architecture diagram needs more. |
Example usage
1import { Image } from '@/ui/Display'23<Image4 src="/architecture.png"5 alt="Request flow from the load balancer to three regions"6 width={1600} // reserves the ratio before loading7 height={900}8 ratio="16 / 9"9 fit="contain" // a diagram: never crop it10 loading="lazy" // but NEVER on the hero11/>1213// Responsive sources. One file for every screen wastes megabytes on the14// connections least able to afford them.15<picture>16 <source type="image/avif" srcSet="/hero.avif 1x, /hero@2x.avif 2x" />17 <source type="image/webp" srcSet="/hero.webp 1x, /hero@2x.webp 2x" />18 <img19 src="/hero.jpg"20 alt="Deployment dashboard showing three healthy regions"21 width={1600}22 height={900}23 sizes="(max-width: 640px) 100vw, 640px"24 fetchPriority="high" // it is the largest contentful paint25 />26</picture>2728// Failure is a state, not an accident. The browser's broken-image icon29// looks like the product is broken.30const [failed, setFailed] = React.useState(false)3132{failed ? (33 <div className="ds-image__error">34 <ImageOff aria-hidden /> Image unavailable35 </div>36) : (37 <img src={src} alt={alt} onError={() => setFailed(true)} />38)}3940// A complex diagram needs more than one sentence — and everyone benefits.41<img src="/arch.png" alt="System architecture" aria-describedby="arch-desc" />42<p id="arch-desc">Requests enter through the load balancer, which…</p>Framework-free HTML
<!-- Dimensions let the browser compute the ratio before the bytes arrive. -->
<img
src="/architecture.png"
alt="Request flow from the load balancer to three regions"
width="1600"
height="900"
loading="lazy"
decoding="async"
/>
<!-- Decorative: alt="" removes it from the accessibility tree. A MISSING
alt does not — some screen readers read the filename instead. -->
<img src="/texture.svg" alt="" />
<!-- Caption is content everyone reads; alt is for people who cannot see it.
They should not say the same thing. -->
<figure>
<img src="/flow.png" alt="Three regions receiving traffic in parallel" />
<figcaption>
Request flow from the load balancer to three regions. Failed health
checks route to the previous build.
</figcaption>
</figure>
<!-- The hero is the largest contentful paint: eager, and prioritised. -->
<img src="/hero.jpg" alt="…" fetchpriority="high" loading="eager" />CSS
.ds-image {
position: relative;
overflow: hidden;
border-radius: var(--radius-lg);
/* Visible while loading, behind a transparent PNG, and around a contained
image. A transparent box shows the page through the gaps. */
background: var(--ds-surface-inset);
/* The single most valuable line here: the space is held before the bytes
arrive, so nothing below reflows. */
aspect-ratio: var(--ratio, 16 / 9);
}
.ds-image img {
inline-size: 100%;
block-size: 100%;
/* Never absent: without it, an image whose intrinsic ratio differs from
its box is squashed rather than cropped. */
object-fit: var(--fit, cover);
object-position: center;
display: block;
}
/* Diagrams and logos: losing an edge changes the meaning. */
.ds-image--contain img { object-fit: contain; padding: 8px; }
.ds-image img {
opacity: 0;
transition: opacity 180ms;
}
.ds-image img[data-loaded='true'] { opacity: 1; }
/* A white product shot on a white surface has invisible boundaries. */
.ds-image--bordered { box-shadow: inset 0 0 0 1px var(--ds-border-subtle); }
/* Text over a photograph: contrast is not assertable without this. */
.ds-image--overlay::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to top, rgb(0 0 0 / 0.6), transparent 60%);
}
@media (prefers-reduced-motion: reduce) {
.ds-image img { transition: none; }
}Component API
Image
| Prop | Type | Default | Description |
|---|---|---|---|
| src* | string | — | The default source. Pair it with srcSet for anything above thumbnail size. |
| alt* | string | — | What the image conveys. Empty string for decoration — never omitted. |
| width / height | number | — | Intrinsic dimensions, so the browser reserves the ratio before loading. |
| ratio | string | — | Overrides the intrinsic ratio when the box is a fixed shape, such as a card thumbnail. |
| fit | 'cover' | 'contain' | 'cover' | Cover for photography, contain for diagrams and logos. |
| loading | 'lazy' | 'eager' | 'lazy' | Eager above the fold. Lazy-loading the hero delays the largest contentful paint. |
| fallback | ReactNode | — | Rendered on error at the same size. The browser’s broken-image icon is never acceptable. |
Professional tips
- Standardise on three or four aspect ratios and use them consistently. Arbitrary ratios make a grid impossible to align without cropping every asset by hand.
- Use a blurred low-quality placeholder for hero images. It gives the eye something at the right shape and colour while the full file arrives.
- Prefer SVG for diagrams, logos and anything with text in it. It scales, it recolours with the theme, and it stays sharp at every density.
- Serve AVIF with a WebP and JPEG fallback. The savings are large and the fallback chain is a few lines of markup.
- For user-uploaded images, crop and resize on the client before upload. It cuts the bandwidth and removes the entire class of "why is my photo sideways" bugs.
Performance
- The hero image is almost always the largest contentful paint. Preload it, set fetchPriority="high", and never lazy-load it.
- Lazy-load everything below the fold. The native loading attribute is enough — an IntersectionObserver implementation adds JavaScript for no gain.
- Set decoding="async" so image decoding does not block the main thread during scroll.
- Size sources to the layout, not to the original file. A 2400px asset displayed at 640px is 90% wasted bytes on every visit.
- Cap srcset density at 2x. The visual difference above that is negligible and the file size is not.
Common mistakes
- No width, height or aspect-ratio, causing layout shift when the image loads.
- A missing alt attribute rather than an explicit alt="" for decoration.
- Alt text describing appearance instead of meaning.
- No object-fit, squashing every image whose ratio differs from its box.
- Lazy-loading the hero, delaying the largest contentful paint.
- One file for every screen, wasting megabytes on mobile.
- Text over an unscrimmed photograph.
- No error state, so a failed asset shows the browser’s broken-image icon.
Real-world recommendations
- Layout shift from unsized images is the most common Core Web Vitals failure, and one attribute fixes it. It is the highest-value line in this whole page.
- Alt text is usually written last and badly. Writing it while placing the image produces better text and takes less time.
- User-uploaded images arrive in every ratio and orientation imaginable. A fixed ratio box with object-fit: cover is what keeps a grid looking deliberate.
- Modern formats are a large, uncontroversial win. AVIF with a WebP fallback typically halves the bytes with no visible difference.