Skip to content

responsive-images

Terminal window
npx skills add https://github.com/crystallizeapi/ai --skill responsive-images

Responsive Images with Crystallize

Crystallize pre-generates every image at a ladder of widths in three formats and serves them from its own CDN at media.crystallize.com. The optimization has already happened. Your job on the frontend is to pick the smallest file that still looks right — not to re-encode anything.

Almost all wasted image bandwidth on a Crystallize storefront comes from two mistakes, both covered below: shipping AVIF because it is newer, and omitting sizes.

Measured on 2026-09-15 against two unrelated production tenants (347 format comparisons, 80 images), and read from @crystallize/reactjs-components v5.0.0. The width ladder and the AVIF/WebP result are observations about today’s transcoder output, not contractual guarantees — re-measure before betting something load-bearing on them.

Rule 0: do not put another image optimizer in front of the CDN

Do not serve Crystallize images through next/image, or any other framework image pipeline, in its default configuration. It is the single most expensive mistake available here.

The variants already exist as static files on Crystallize’s CDN. Routing them through a hosting platform’s image optimizer:

  • produces no smaller file — it re-encodes an already-encoded derivative
  • adds a proxy hop, and a transform on the first request for every unseen size
  • bills you for image-optimization units and platform egress for bytes the Crystallize CDN already serves

Use a plain <picture> / <img> pointing straight at media.crystallize.com. That is what the component below emits.

If a project is already committed to next/image, pass unoptimized, or give it a custom loader that returns the @{width} URL — but at that point it is doing strictly less than the Crystallize Image component, with more configuration.

What the API actually returns

Each image carries a list of variants. Verified shape:

https://media.crystallize.com/{tenant}/{path}/@{width}/{filename}.{ext}
Widths drawn from 100, 200, 500, 768, 1024, 1366, 1600, 1920, 3200
Formats avif and webp always, plus the original (jpeg or png)
Fields url, key, width, height, size

The width ladder is capped at the source image’s own width, so it differs per image. A 1200px original yields 100/200/500/768/1024; a 500px original yields only 100/200/500. In a sample of 80 images, eight distinct width sets appeared.

Never hardcode the ladder. Build srcset from the variants the API returned for that image, or you will emit URLs that 404 and a browser that picks them will show nothing.

Query the fields you need — including size

firstImage {
url
altText
width
height
variants {
url
width
height
size
}
}

Always select size. It is not decoration: the React component uses it to decide whether AVIF is worth emitting (see below). Omit it and the component silently ships both formats, which on measured data is the more expensive outcome.

On Discovery you can also trim the payload server-side — variants takes arguments:

variants(types: "webp", minWidth: 400, maxWidth: 1600) {
url
width
size
}

Useful for a listing page that will never render a 3200px hero. Catalogue’s variants takes no arguments — filter client-side there.

Format: measure, do not assume AVIF

Conventional advice says prefer AVIF. On Crystallize’s current transcoder settings that is usually wrong.

Comparisons (2 tenants, same width, both formats present) 347
WebP produced the smaller file 343
AVIF produced the smaller file 4
Average advantage to WebP ~25–30%

AVIF generally wins at equal visual quality; what matters for payload is file size at the quality the encoder actually targeted, and here WebP wins nearly every time. On one tenant AVIF averaged larger than the original JPEG.

So: ship one modern format, not both. Two <source> elements do not make the page faster — the browser takes the first it supports, and if that is the larger AVIF you have paid for the privilege. Keep the original-format source as the compatibility fallback.

The component already does this for you, if you queried size.

Use the component

@crystallize/reactjs-components v5 implements the above. Spread the API image object into it:

import { Image } from "@crystallize/reactjs-components";
<Image {...image} sizes="(max-width: 600px) 90vw, 700px" className="product-image" loading="lazy" />;

What it does for you:

  • builds a srcset per format from variants, with w descriptors
  • emits <source type="image/avif"> only when the first AVIF variant is smaller than the first WebP variant — the comparison needs size, and falls back to emitting both when it is missing
  • keeps a <source> for the original format and a plain src on the <img> as the fallback
  • sets width/height from the largest variant, which is what prevents layout shift
  • resolves alt as alt → altText (from the API) → fallbackAlt → ""

What it does not do, and you must:

  • pass sizes — it has no default, see below
  • pass loading and fetchPriority — they forward to the <img>, but nothing is set automatically

Two surprises worth knowing before you style it:

  • It always renders <figure> wrapping <picture>, plus a <figcaption> even when empty. Budget for that in CSS, or use the render-prop escape hatch.
  • _availableSizes / _availableFormats synthesize URLs from the @{width} scheme instead of using variants. Because the ladder is per-image, this can invent URLs that do not exist. Prefer passing real variants.

For full markup control, pass a function as children and receive { srcSet, srcSetWebp, srcSetAvif, useAvif, useWebP, sizes, media, src, alt, width, height, originalFileExtension }.

Not using React? See references/without-react.md.

sizes is the whole ballgame

This is the most common and most expensive mistake on the list.

srcset w-descriptors tell the browser how wide each file is. They say nothing about how big the image will be on the page. sizes is what tells it that — and the browser must choose a file before it has laid the page out, so it cannot work this out on its own.

Omit sizes and the browser assumes 100vw — full viewport width. A 180px thumbnail on a 1440px-wide laptop then downloads the 1024px file. Every time, on every thumbnail.

Write sizes to mirror the CSS that will actually size the element, gutters and column counts included:

<!-- Four-column grid at desktop, two at tablet, one at mobile -->
sizes="(max-width: 600px) calc(100vw - 32px), (max-width: 1024px) calc(50vw - 24px), calc(25vw - 32px)"
<!-- Full-bleed hero -->
sizes="100vw"
<!-- Fixed-width thumbnail — no media query needed -->
sizes="180px"

Checking it: open DevTools, find the <img>, compare currentSrc against the element’s rendered width times the device pixel ratio. If currentSrc is much wider, sizes is lying.

When sizes and the CSS disagree, the CSS wins visually and sizes wins on bandwidth — so a wrong sizes costs you bytes with nothing to show for it.

LCP and CLS

  • The hero is the LCP element. Give it loading="eager" and fetchPriority="high", and never loading="lazy" — lazy-loading the LCP image reliably makes the score worse.
  • Everything below the fold gets loading="lazy".
  • Always render width and height so the browser can reserve the box. The component does this from the largest variant; if you hand-roll, do it yourself. This is the entire fix for image-driven layout shift.
  • Do not lazy-load images already in the viewport on first paint — the request starts later than it needs to.

Failure modes

Symptom Cause Fix
Thumbnails download 1024px files sizes missing — browser assumed 100vw Write sizes to match the rendered width
Both AVIF and WebP <source> emitted size not selected in the GraphQL query Add size to variants
Images heavier after “upgrading” to AVIF AVIF is usually larger here Ship WebP; keep the original as fallback
Some srcset URLs 404 Width ladder hardcoded, or _availableSizes used Build srcset from the returned variants
Page jumps as images load No width/height on the <img> Render both; they set the aspect ratio
Poor LCP on the product page Hero is loading="lazy" eager + fetchPriority="high"
Image bill higher than expected Images routed through a framework optimizer Serve straight from media.crystallize.com
alt is empty everywhere altText not selected, or not authored Query altText; author it in the PIM

[[query]] covers the Catalogue and Discovery queries these fields come from, and [[content-model]] covers the Images component itself — including that product variant images are built-in rather than components. [[mutation]] covers uploading images and setting altText.


Reference Details

Responsive Crystallize Images Without React

The @crystallize/reactjs-components Image component is React-only. Everywhere else — Svelte, Vue, Astro, Remix loaders emitting plain HTML, email templates, server-rendered templates — build the same markup by hand. This page is the fallback, not the recommendation: if you are in React, use the component.

What you are reproducing

<picture>
<source type="image/webp" srcset="…400w, …800w, …1200w" sizes="…" />
<source type="image/jpeg" srcset="…400w, …800w, …1200w" sizes="…" />
<img src="…fallback.jpeg" alt="…" width="1200" height="800" loading="lazy" />
</picture>

Four things make it correct:

  1. One <source> per format, modern format first — the browser takes the first it supports.
  2. srcset entries carry w descriptors matching the file’s real pixel width.
  3. sizes is present on every <source> and describes the rendered width, not the file width.
  4. <img> carries src (fallback), alt, width, height.

Building it from the API response

type Variant = { url: string; width: number; height?: number; size?: number };
const extOf = (url: string) => url.split(".").pop()!.toLowerCase();
const srcsetFor = (variants: Variant[], ext: string) =>
variants
.filter((v) => extOf(v.url) === ext)
.sort((a, b) => a.width - b.width)
.map((v) => `${v.url} ${v.width}w`)
.join(", ");

Build from the variants the API returned. The width ladder is capped at the source image’s own width, so it differs per image — a hardcoded list produces URLs that 404.

Choosing the modern format

Ship one modern format. Two <source> elements do not make the page faster; the browser takes the first it supports, and on Crystallize’s current transcoder output that is usually the larger file.

/** Pick the smaller of webp/avif at a common width; default to webp. */
function pickModernFormat(variants: Variant[]): "webp" | "avif" {
const at = (ext: string) =>
variants.filter((v) => extOf(v.url) === ext && v.size).sort((a, b) => a.width - b.width);
const [webp] = at("webp");
const [avif] = at("avif");
if (!webp?.size || !avif?.size) return "webp";
return avif.size < webp.size ? "avif" : "webp";
}

This mirrors what the React component does. It needs size in the query — without it, default to webp, which wins the large majority of the time.

The original format is whatever the source was, jpeg or png. Derive it rather than assuming:

const originalExt = (image: { url: string }) => {
const ext = extOf(image.url);
return ext === "jpg" ? "jpeg" : ext;
};

Putting it together

function pictureHtml(image: { url: string; altText?: string; variants: Variant[] }, sizes: string) {
const modern = pickModernFormat(image.variants);
const original = originalExt(image);
const biggest = [...image.variants].sort((a, b) => b.width - a.width)[0];
return `
<picture>
<source type="image/${modern}" srcset="${srcsetFor(image.variants, modern)}" sizes="${sizes}">
<source type="image/${original}" srcset="${srcsetFor(image.variants, original)}" sizes="${sizes}">
<img src="${image.url}" alt="${image.altText ?? ""}"
width="${biggest?.width ?? ""}" height="${biggest?.height ?? ""}" loading="lazy">
</picture>`;
}

Escape altText for the target context — it is author-entered content, not a literal.

Art direction: a different crop per breakpoint

sizes picks a size. When you need a different image — a tall portrait crop on mobile, a wide one on desktop — that is media, and it takes separate <source> elements:

<picture>
<source media="(max-width: 600px)" type="image/webp" srcset="…portrait srcset…" sizes="100vw" />
<source type="image/webp" srcset="…landscape srcset…" sizes="(max-width: 1200px) 100vw, 1200px" />
<img src="…" alt="…" width="1600" height="900" />
</picture>

Two different crops are two different images in the PIM. Do not try to fake art direction with object-fit alone — see focal point below for the case where one image is genuinely enough.

Focal point

focalPoint ({ x, y }, normalized 0–1) is authored in the PIM and tells you where the subject is. It is what makes a single image survive being cropped to different aspect ratios:

.card-image {
aspect-ratio: 4 / 3;
object-fit: cover;
object-position: var(--focal-x, 50%) var(--focal-y, 50%);
}
const style = image.focalPoint ? `--focal-x:${image.focalPoint.x * 100}%;--focal-y:${image.focalPoint.y * 100}%` : "";

Without it, object-fit: cover crops from the centre and decapitates people in portrait shots.

Don’t

  • Don’t route these URLs through a framework image optimizer. The variants are already static files on the CDN; re-optimizing yields no smaller file and bills you for the transform and the egress. See Rule 0 in SKILL.md.
  • Don’t omit sizes. The browser then assumes 100vw and downloads the largest candidate for a 200px thumbnail.
  • Don’t put sizes only on the <img>. Each <source> needs it too; <img>-only leaves the <source> elements defaulting to 100vw.
  • Don’t lazy-load the LCP image. The hero gets loading="eager" and fetchpriority="high".
  • Don’t hardcode the width ladder. It is capped per image at the source width.


Crystallize AI