Skip to content
Strife Docs

How-to guides

Responsive images

Serve each visitor an image file that fits their screen: add widths to fields.image(), write srcset from the value and describe your layout with sizes. Patterns for heroes, content images, card grids and fixed-size images.

A responsive image gives the browser the same picture in several widths and lets it pick the one that fits the visitor's screen. A phone then downloads a phone-sized file, not the desktop one, and a high-density screen still gets a sharp image.

The work is split in two:

  • Strife writes srcset. A field with widths gets a list of candidates — the same crop in several widths — whenever an editor saves the image.
  • Your site writes sizes. Only your layout knows how wide the image is on the page. On lazy-loaded images you can let the browser measure it instead.

The browser does the rest: it weighs the rendered width, the screen's pixel density and things like a data-saver setting, and picks a candidate. srcset and sizes describe; they don't instruct.

1. Add widths to the field

TypeScript
fields.image({
  label: 'Image',
  width: 1344,
  height: 896,
  format: 'avif',
  widths: 'auto',
})
  • width is the largest candidate. Set it to the largest width the image is shown at, in CSS pixels, times two — 1344 for an image that is at most 672px wide. The browser never gets a larger file than this.
  • widths: 'auto' gives about 25 % steps from 320px up to width. Use an array (widths: [400, 800, 1344]) when you know your layout's widths.
  • The field's crop is the same in every candidate; only the size changes.

From the next time an editor picks or crops an image in that field, in either of Strife Studio's interfaces, its value has a source.srcset:

JSON
{
  "width": 1344,
  "height": 896,
  "alt": "A lake below wooded mountains",
  "source": {
    "url": "https://cdn.strife.app/…/w:1344/…",
    "srcset": "https://cdn.strife.app/…/w:320/… 320w, https://cdn.strife.app/…/w:400/… 400w, …, https://cdn.strife.app/…/w:1344/… 1344w",
    "placeholder": "#4d6a6f"
  }
}
Note:

source.srcset is absent on images saved before you added widths, on SVG and GIF images (vector graphics scale on their own, and an animated GIF would be rendered once per width), on images written through the API rather than in Strife Studio, and, in Studio's classic interface, on images smaller than the field's size. Your markup keeps working: the browser uses src.

2. Write the markup

Every pattern below uses the same three things: srcset from the value, a sizes that describes your layout, and width/height so the browser can reserve the space before the image arrives.

CSS
img {
  max-inline-size: 100%;
  block-size: auto;
}

Full-width hero

The page's largest image at the top, usually its Largest Contentful Paint (LCP) element. It spans the viewport, so sizes="100vw".

Astro
<img
  src={hero.source.url}
  srcset={hero.source.srcset}
  sizes="100vw"
  width={hero.width}
  height={hero.height}
  alt={hero.alt}
  fetchpriority="high"
/>
  • Never put loading="lazy" on the LCP image, and use fetchpriority="high" on one or two images at most.
  • The image is in the HTML from the server, so the browser finds it right away; you don't need a <link rel="preload">.
  • For a 3:2 hero shown up to about 1440 CSS pixels wide, width: 2880, height: 1920.

To keep a hero from growing too tall on wide screens, cap its height with CSS. The box is then wider than the image, so the image still scales with the width and sizes is still right; only a little height is cropped.

CSS
.hero {
  inline-size: 100%;
  aspect-ratio: 3 / 2;
  max-block-size: min(100dvh - 4rem, 1000px);
  overflow: hidden;
}
.hero img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
}
Warning:

Give the box a definite width (inline-size: 100%). With width: auto, aspect-ratio turns the height cap into a width cap and the box shrinks instead of cropping.

Content image

An image in a text column, further down the page. It loads lazily, so sizes can start with auto: the browser measures the image's width when it is about to load. Browsers that don't support auto yet read the rest of the list, so describe the column after the comma.

Astro
<img
  src={image.source.url}
  srcset={image.source.srcset}
  sizes="auto, (min-width: 44.5rem) 42rem, calc(100vw - 2.5rem)"
  loading="lazy"
  width={image.width}
  height={image.height}
  alt={image.alt}
/>

The same value can appear in several places with a different sizes in each; the field's width only caps the largest candidate.

Card grid

Many images in a grid that changes its number of columns. Describe one card's width per breakpoint. The cards below the fold load lazily with auto:

Astro
<img
  src={card.source.url}
  srcset={card.source.srcset}
  sizes="auto, (min-width: 64rem) 33vw, (min-width: 40rem) 50vw, 100vw"
  loading="lazy"
  width={card.width}
  height={card.height}
  alt={card.alt}
/>

The first row is probably visible when the page loads, and an image in the viewport should not be lazy-loaded. Leave out loading="lazy" there, and with it auto:

Astro
<img
  src={card.source.url}
  srcset={card.source.srcset}
  sizes="(min-width: 64rem) 33vw, (min-width: 40rem) 50vw, 100vw"
  width={card.width}
  height={card.height}
  alt={card.alt}
/>
Warning:

Use sizes="auto" only together with loading="lazy". On an image that isn't lazy-loaded, browsers treat it as a 300×150 box and ignore the rest of the list.

Fixed size

Avatars, logos and thumbnails are always the same size in CSS; only the screen's pixel density changes. Two candidates — 1x and 2x — are enough, and sizes is just the image's width:

TypeScript
fields.image({ label: 'Avatar', width: 192, height: 192, format: 'avif', widths: [96, 192] })
Astro
<img
  src={avatar.source.url}
  srcset={avatar.source.srcset}
  sizes="96px"
  width={avatar.width}
  height={avatar.height}
  alt={avatar.alt}
/>

The same value works at another size with another sizes, for example sizes="48px" in a list.

3. A complete example

The patterns above differ only in sizes and in whether the image is lazy-loaded. A small component keeps the rest in one place: an image without a value renders nothing, an image without source.srcset falls back to src, sizes="auto" is used only on lazy images, and the placeholder colour fills the space while the file loads.

src/components/StrifeImage.astro
---
import type { Image } from '@strifeapp/types';

interface Props {
  image: Image | null | undefined;
  /** How wide the image is in your layout, e.g. "100vw" or "(min-width: 64rem) 33vw, 100vw". */
  sizes: string;
  /** The page's main image (its LCP element): loaded at once, at high priority. */
  priority?: boolean;
  class?: string;
}

const { image, sizes, priority = false, class: className } = Astro.props;
const source = image?.source;
const srcset = source?.srcset || undefined;
---

{image && source?.url && (
  <img
    class={className}
    src={source.url}
    srcset={srcset}
    sizes={srcset ? (priority ? sizes : `auto, ${sizes}`) : undefined}
    width={image.width}
    height={image.height}
    alt={image.alt ?? ''}
    loading={priority ? undefined : 'lazy'}
    decoding={priority ? undefined : 'async'}
    fetchpriority={priority ? 'high' : undefined}
    style={source.placeholder ? `background-color: ${source.placeholder}` : undefined}
  />
)}

<style>
  img {
    max-inline-size: 100%;
    block-size: auto;
  }
</style>

Use it with the field from step 1 and a sizes per place in your layout:

schemas/home.ts
import { defineType, fields } from '@strifeapp/schema';

export const home = defineType({
  name: 'home',
  title: 'Home',
  fields: {
    heading: fields.text({ label: 'Heading', localizable: true }),
    // A full-width hero, at most 1600 CSS pixels wide: 3200 for 2x screens.
    hero: fields.image({ label: 'Hero', width: 3200, height: 1350, format: 'avif', widths: 'auto' }),
  },
});
src/pages/index.astro
---
import StrifeImage from '../components/StrifeImage.astro';

const page = await getPage('/'); // however your site loads the document
---

<!-- The LCP image: at once, at high priority, across the whole viewport. -->
<StrifeImage image={page.hero} sizes="100vw" priority />

<!-- Further down: lazy, in a column at most 42rem wide. -->
<StrifeImage image={page.figure} sizes="(min-width: 44.5rem) 42rem, calc(100vw - 2.5rem)" />

The hero then reaches the browser like this (URLs shortened):

HTML
<img
  src="https://cdn.strife.app/…/w:1672/….avif"
  srcset="https://cdn.strife.app/…/w:320/….avif 320w, …, https://cdn.strife.app/…/w:1672/….avif 1672w"
  sizes="100vw"
  width="3200"
  height="1350"
  alt="A skater mid-air above a concrete ramp"
  fetchpriority="high"
  style="background-color: #9aa4ac"
/>

width and height are the field's size: they only give the aspect ratio, so the browser reserves the right space before the file arrives and nothing jumps. In this example the original is 1672 pixels wide, so the largest candidate is 1672w, not 3200w: Strife never makes a candidate wider than the original.

Check what the browser picked

Open the page with DevTools' device toolbar, select the image in the Elements panel and read currentSrc in the Properties pane (or filter the Network panel by Img). The w: in the URL is the candidate's width. For the hero above, a 390-pixel phone at 3x needs 1170 pixels and gets the 1216w candidate, and a 768-pixel tablet at 2x needs 1536 and gets 1672w, the largest there is. Reload after each change of screen size: a browser that already has a larger candidate in its cache may keep using it.

Note:

A change to width, height or widths in your schema reaches each image the next time an editor picks or crops it; until then the image keeps the candidates it was saved with. To get large candidates, upload originals at least as wide as the field's width.

Live preview

While an editor zooms or pans an image in Strife Studio, the preview receives a temporary picture in source.url and no source.srcset; the candidates follow when the editor lets go. If your page updates images live (through subscribe, see Custom Live Preview implementation), remove srcset and sizes when the value has none. Otherwise the browser keeps showing the old candidates instead of the new picture:

JavaScript
img.src = image.source.url;
if (image.source.srcset) {
  img.srcset = image.source.srcset;
  img.sizes = sizes;
} else {
  img.removeAttribute('srcset');
  img.removeAttribute('sizes');
}

Placeholder

source.placeholder is the image's average color. Use it as a background so the reserved space isn't empty while the image loads:

Astro
<img … style={image.source.placeholder ? `background-color: ${image.source.placeholder}` : undefined} />

When the shape has to change

Everything here is the same crop in different sizes. When a design needs a different crop per screen — a tall image on phones and a wide one on desktop — that is art direction, done with <picture> and a <source media> per shape. Cropping a wide image with CSS to fill a tall phone screen makes the phone download pixels it never shows.

Further reading