Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

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.

Ratio reserved
Space held before the bytes arrive
No ratio
Everything below jumps when it loads

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.

coverPhotography
containDiagrams, logos

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.

Loading
Loaded
Failed
Image unavailable

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.

Request flow from the load balancer to three regions. Failed health checks route to the previous build.

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.

16:9
4:3
Square
Portrait
Contain
Loading
Image unavailable
Failed
Square corners
Zoomable

Anatomy

Every part, every measurement, and the reason it is that number.

Request flow from the load balancer to three regions.

A ratio-locked box holding the image, with a caption beneath it as a sibling rather than an overlay.

  1. Ratio boxaspect-ratio on the wrapper

    Held before anything loads. This one property removes most of the layout shift a page suffers.

  2. Object fitcover or contain

    Never absent. Without it, any image whose intrinsic ratio differs from its box is squashed rather than cropped.

  3. 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.

  4. Radius12px, clipped

    On the wrapper with overflow hidden, so the image is clipped rather than relying on the image having its own rounded corners.

  5. Loading stateSkeleton at the same ratio

    Occupying the exact final dimensions, so the transition to the loaded image moves nothing.

  6. 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.

  7. Caption12px, 8px below

    A figcaption sibling. Overlaying it on the image makes contrast dependent on the picture, which changes every time the asset does.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--space-2Gap from image to caption

Radius

TokenValueUsed for
--radius-lgStandalone images and cards
--radius-mdThumbnails in lists

Typography

TokenValueUsed for
--text-captionCaption

Motion

TokenValueUsed for
--duration-normalFade from placeholder to loaded

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightRadiusMin widthMax widthWhen to use
Thumbnail40–64px8px——In a list row or a table cell. Square, so a mixed set of source ratios stays aligned.
Card media16:9 or 4:312px top corners——Full-bleed to the card edge, with the radius only on the corners it touches.
Inline content—12px—40remMatched to the prose measure so it does not break the reading column.
Full-bleed—0100%—A hero or a section break, edge to edge with no radius.
Ratios16:9, 4:3, 1:1, 3:4———A small set, used consistently. Arbitrary ratios make a grid impossible to align.

Do

<img width={1600} height={900} />
or: aspect-ratio: 16 / 9
Always reserve the aspect ratioIt is the single largest source of layout shift on most pages, and layout shift is why people tap the wrong thing.
alt="Deployment failed with a health-check timeout"alt="screenshot"
Write alt text about meaning, not appearanceA screenshot of an error is "the deployment failed with a health-check timeout", not "screenshot of a dialog box". The second describes pixels; the first conveys the point.
Set object-fit explicitlyCover for photography, contain for diagrams. Without either, any image whose ratio differs from its box is squashed.
Image unavailable
Handle failure explicitlyThe browser’s broken-image icon looks like the product is broken. A labelled placeholder at the same size says the asset is missing and nothing else went wrong.

Don't

<img src="hero-final-v3-2.png" />
→ “hero dash final dash v three dash two dot png”
Do not omit alt entirelyA missing alt makes some screen readers read the filename. An empty alt="" is a deliberate statement that the image is decorative — the two are not the same.
<img loading="lazy" /> on the hero
Do not lazy-load above the foldThe hero image is usually the largest contentful paint. Deferring it makes the page measurably slower at the only moment the user is watching.
Unreadable
Do not put text on an image without a scrimContrast against a photograph cannot be asserted, and the asset will be swapped for a brighter one eventually.
hero@2400.jpg → 375px viewport → 2.8 MB
Do not ship one file for every screenA 2400px hero sent to a 375px phone is several megabytes of bandwidth thrown away, on the connection least able to afford it.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.1.1Non-text ContentA1.4.5Images of TextAA1.4.11Non-text ContrastAA2.2.2Pause, Stop, HideA

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

TabNothing — an image is not interactive unless it is inside a link or a button.
EnterOpens a zoomable image in a lightbox, when the wrapper is a real button.
EscCloses 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.
AttributeApplied toNotes
altEvery imageRequired. Describes what the image conveys, not what it depicts.
alt=""Decorative imagesA deliberate empty string. It removes the image from the accessibility tree; a missing alt does not.
<figure> / <figcaption>Captioned imagesThe caption is content everyone reads. It does not replace alt text, and it should not repeat it.
role="img"A CSS or SVG imageWith aria-label, when the picture is content rather than background.
aria-describedbyA complex diagramPointing at a longer description in the page. Alt text is one sentence; an architecture diagram needs more.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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 / heightnumber—Intrinsic dimensions, so the browser reserves the ratio before loading.
ratiostring—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.
fallbackReactNode—Rendered on error at the same size. The browser’s broken-image icon is never acceptable.

Notes

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.