responsive-images
npx skills add https://github.com/crystallizeapi/ai --skill responsive-imagesResponsive 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-componentsv5.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
srcsetper format fromvariants, withwdescriptors - emits
<source type="image/avif">only when the first AVIF variant is smaller than the first WebP variant — the comparison needssize, and falls back to emitting both when it is missing - keeps a
<source>for the original format and a plainsrcon the<img>as the fallback - sets
width/heightfrom the largest variant, which is what prevents layout shift - resolves
altasalt→altText(from the API) →fallbackAlt→""
What it does not do, and you must:
- pass
sizes— it has no default, see below - pass
loadingandfetchPriority— 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/_availableFormatssynthesize URLs from the@{width}scheme instead of usingvariants. Because the ladder is per-image, this can invent URLs that do not exist. Prefer passing realvariants.
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"andfetchPriority="high", and neverloading="lazy"— lazy-loading the LCP image reliably makes the score worse. - Everything below the fold gets
loading="lazy". - Always render
widthandheightso 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 |
Related skills
[[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:
- One
<source>per format, modern format first — the browser takes the first it supports. srcsetentries carrywdescriptors matching the file’s real pixel width.sizesis present on every<source>and describes the rendered width, not the file width.<img>carriessrc(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 assumes100vwand downloads the largest candidate for a 200px thumbnail. - Don’t put
sizesonly on the<img>. Each<source>needs it too;<img>-only leaves the<source>elements defaulting to100vw. - Don’t lazy-load the LCP image. The hero gets
loading="eager"andfetchpriority="high". - Don’t hardcode the width ladder. It is capped per image at the source width.
Crystallize AI