|
| 1 | +--- |
| 2 | +name: responsive-images |
| 3 | +description: > |
| 4 | + Render Crystallize images efficiently on the web — srcset, sizes, picture, format choice, lazy |
| 5 | + loading, LCP and CLS. Use when building product galleries, hero images, thumbnails, grids or any |
| 6 | + page that displays Crystallize media; when images are too heavy, the page scores badly on |
| 7 | + Lighthouse or Core Web Vitals, or the wrong image size is being downloaded; when choosing between |
| 8 | + AVIF, WebP and the original format; when deciding whether to use next/image or another framework |
| 9 | + image component with Crystallize; or when querying image variants from the Catalogue or Discovery |
| 10 | + API. Trigger on "srcset", "sizes", "responsive images", "picture element", "image optimization", |
| 11 | + "image variants", "media.crystallize.com", "webp", "avif", "lazy loading", "fetchpriority", |
| 12 | + "layout shift", "CLS", "LCP", "next/image", "image component", "@crystallize/reactjs-components", |
| 13 | + "thumbnail", "image payload", "images too big". |
| 14 | +metadata: |
| 15 | + author: Crystallize |
| 16 | + version: "1.0" |
| 17 | +--- |
| 18 | + |
| 19 | +# Responsive Images with Crystallize |
| 20 | + |
| 21 | +Crystallize pre-generates every image at a ladder of widths in three formats and serves them from its |
| 22 | +own CDN at `media.crystallize.com`. **The optimization has already happened.** Your job on the frontend |
| 23 | +is to pick the smallest file that still looks right — not to re-encode anything. |
| 24 | + |
| 25 | +Almost all wasted image bandwidth on a Crystallize storefront comes from two mistakes, both covered |
| 26 | +below: shipping AVIF because it is newer, and omitting `sizes`. |
| 27 | + |
| 28 | +> Measured on 2026-09-15 against two unrelated production tenants (347 format comparisons, 80 images), |
| 29 | +> and read from `@crystallize/reactjs-components` v5.0.0. The width ladder and the AVIF/WebP result are |
| 30 | +> observations about today's transcoder output, not contractual guarantees — re-measure before betting |
| 31 | +> something load-bearing on them. |
| 32 | +
|
| 33 | +## Rule 0: do not put another image optimizer in front of the CDN |
| 34 | + |
| 35 | +**Do not serve Crystallize images through `next/image`, or any other framework image pipeline, in its |
| 36 | +default configuration.** It is the single most expensive mistake available here. |
| 37 | + |
| 38 | +The variants already exist as static files on Crystallize's CDN. Routing them through a hosting |
| 39 | +platform's image optimizer: |
| 40 | + |
| 41 | +- produces **no smaller file** — it re-encodes an already-encoded derivative |
| 42 | +- adds a proxy hop, and a transform on the first request for every unseen size |
| 43 | +- bills you for image-optimization units and platform egress for bytes the Crystallize CDN already |
| 44 | + serves |
| 45 | + |
| 46 | +Use a plain `<picture>` / `<img>` pointing straight at `media.crystallize.com`. That is what the |
| 47 | +component below emits. |
| 48 | + |
| 49 | +If a project is already committed to `next/image`, pass `unoptimized`, or give it a custom loader that |
| 50 | +returns the `@{width}` URL — but at that point it is doing strictly less than the Crystallize `Image` |
| 51 | +component, with more configuration. |
| 52 | + |
| 53 | +## What the API actually returns |
| 54 | + |
| 55 | +Each image carries a list of variants. Verified shape: |
| 56 | + |
| 57 | +``` |
| 58 | +https://media.crystallize.com/{tenant}/{path}/@{width}/{filename}.{ext} |
| 59 | +``` |
| 60 | + |
| 61 | +| | | |
| 62 | +| ----------- | ------------------------------------------------------------- | |
| 63 | +| **Widths** | drawn from `100, 200, 500, 768, 1024, 1366, 1600, 1920, 3200` | |
| 64 | +| **Formats** | `avif` and `webp` always, plus the original (`jpeg` or `png`) | |
| 65 | +| **Fields** | `url`, `key`, `width`, `height`, `size` | |
| 66 | + |
| 67 | +**The width ladder is capped at the source image's own width, so it differs per image.** A 1200px |
| 68 | +original yields `100/200/500/768/1024`; a 500px original yields only `100/200/500`. In a sample of 80 |
| 69 | +images, eight distinct width sets appeared. |
| 70 | + |
| 71 | +**Never hardcode the ladder.** Build `srcset` from the variants the API returned for _that_ image, or |
| 72 | +you will emit URLs that 404 and a browser that picks them will show nothing. |
| 73 | + |
| 74 | +## Query the fields you need — including `size` |
| 75 | + |
| 76 | +```graphql |
| 77 | +firstImage { |
| 78 | + url |
| 79 | + altText |
| 80 | + width |
| 81 | + height |
| 82 | + variants { |
| 83 | + url |
| 84 | + width |
| 85 | + height |
| 86 | + size |
| 87 | + } |
| 88 | +} |
| 89 | +``` |
| 90 | + |
| 91 | +**Always select `size`.** It is not decoration: the React component uses it to decide whether AVIF is |
| 92 | +worth emitting (see below). Omit it and the component silently ships both formats, which on measured |
| 93 | +data is the more expensive outcome. |
| 94 | + |
| 95 | +On **Discovery** you can also trim the payload server-side — `variants` takes arguments: |
| 96 | + |
| 97 | +```graphql |
| 98 | +variants(types: "webp", minWidth: 400, maxWidth: 1600) { |
| 99 | + url |
| 100 | + width |
| 101 | + size |
| 102 | +} |
| 103 | +``` |
| 104 | + |
| 105 | +Useful for a listing page that will never render a 3200px hero. Catalogue's `variants` takes no |
| 106 | +arguments — filter client-side there. |
| 107 | + |
| 108 | +## Format: measure, do not assume AVIF |
| 109 | + |
| 110 | +Conventional advice says prefer AVIF. **On Crystallize's current transcoder settings that is usually |
| 111 | +wrong.** |
| 112 | + |
| 113 | +| | | |
| 114 | +| --------------------------------------------------------- | ----------- | |
| 115 | +| Comparisons (2 tenants, same width, both formats present) | **347** | |
| 116 | +| WebP produced the smaller file | **343** | |
| 117 | +| AVIF produced the smaller file | **4** | |
| 118 | +| Average advantage to WebP | **~25–30%** | |
| 119 | + |
| 120 | +AVIF generally wins at _equal visual quality_; what matters for payload is file size at the quality |
| 121 | +the encoder actually targeted, and here WebP wins nearly every time. On one tenant AVIF averaged |
| 122 | +larger than the original JPEG. |
| 123 | + |
| 124 | +So: **ship one modern format, not both.** Two `<source>` elements do not make the page faster — the |
| 125 | +browser takes the first it supports, and if that is the larger AVIF you have paid for the privilege. |
| 126 | +Keep the original-format source as the compatibility fallback. |
| 127 | + |
| 128 | +The component already does this for you, _if_ you queried `size`. |
| 129 | + |
| 130 | +## Use the component |
| 131 | + |
| 132 | +`@crystallize/reactjs-components` v5 implements the above. Spread the API image object into it: |
| 133 | + |
| 134 | +```tsx |
| 135 | +import { Image } from "@crystallize/reactjs-components"; |
| 136 | + |
| 137 | +<Image {...image} sizes="(max-width: 600px) 90vw, 700px" className="product-image" loading="lazy" />; |
| 138 | +``` |
| 139 | + |
| 140 | +What it does for you: |
| 141 | + |
| 142 | +- builds a `srcset` per format from `variants`, with `w` descriptors |
| 143 | +- emits `<source type="image/avif">` **only when the first AVIF variant is smaller than the first WebP |
| 144 | + variant** — the comparison needs `size`, and falls back to emitting both when it is missing |
| 145 | +- keeps a `<source>` for the original format and a plain `src` on the `<img>` as the fallback |
| 146 | +- sets `width`/`height` from the largest variant, which is what prevents layout shift |
| 147 | +- resolves `alt` as `alt` → `altText` (from the API) → `fallbackAlt` → `""` |
| 148 | + |
| 149 | +What it does **not** do, and you must: |
| 150 | + |
| 151 | +- **pass `sizes`** — it has no default, see below |
| 152 | +- pass `loading` and `fetchPriority` — they forward to the `<img>`, but nothing is set automatically |
| 153 | + |
| 154 | +Two surprises worth knowing before you style it: |
| 155 | + |
| 156 | +- It always renders `<figure>` wrapping `<picture>`, **plus a `<figcaption>` even when empty**. Budget |
| 157 | + for that in CSS, or use the render-prop escape hatch. |
| 158 | +- `_availableSizes` / `_availableFormats` synthesize URLs from the `@{width}` scheme instead of using |
| 159 | + `variants`. Because the ladder is per-image, this can invent URLs that do not exist. Prefer passing |
| 160 | + real `variants`. |
| 161 | + |
| 162 | +For full markup control, pass a function as `children` and receive |
| 163 | +`{ srcSet, srcSetWebp, srcSetAvif, useAvif, useWebP, sizes, media, src, alt, width, height, originalFileExtension }`. |
| 164 | + |
| 165 | +Not using React? See [references/without-react.md](references/without-react.md). |
| 166 | + |
| 167 | +## `sizes` is the whole ballgame |
| 168 | + |
| 169 | +This is the most common and most expensive mistake on the list. |
| 170 | + |
| 171 | +`srcset` w-descriptors tell the browser **how wide each file is**. They say nothing about how big the |
| 172 | +image will be on the page. `sizes` is what tells it that — and the browser must choose a file |
| 173 | +_before_ it has laid the page out, so it cannot work this out on its own. |
| 174 | + |
| 175 | +**Omit `sizes` and the browser assumes `100vw`** — full viewport width. A 180px thumbnail on a |
| 176 | +1440px-wide laptop then downloads the 1024px file. Every time, on every thumbnail. |
| 177 | + |
| 178 | +Write `sizes` to mirror the CSS that will actually size the element, gutters and column counts |
| 179 | +included: |
| 180 | + |
| 181 | +```html |
| 182 | +<!-- Four-column grid at desktop, two at tablet, one at mobile --> |
| 183 | +sizes="(max-width: 600px) calc(100vw - 32px), (max-width: 1024px) calc(50vw - 24px), calc(25vw - 32px)" |
| 184 | + |
| 185 | +<!-- Full-bleed hero --> |
| 186 | +sizes="100vw" |
| 187 | + |
| 188 | +<!-- Fixed-width thumbnail — no media query needed --> |
| 189 | +sizes="180px" |
| 190 | +``` |
| 191 | + |
| 192 | +Checking it: open DevTools, find the `<img>`, compare `currentSrc` against the element's rendered |
| 193 | +width times the device pixel ratio. If `currentSrc` is much wider, `sizes` is lying. |
| 194 | + |
| 195 | +When `sizes` and the CSS disagree, the CSS wins visually and `sizes` wins on bandwidth — so a wrong |
| 196 | +`sizes` costs you bytes with nothing to show for it. |
| 197 | + |
| 198 | +## LCP and CLS |
| 199 | + |
| 200 | +- **The hero is the LCP element.** Give it `loading="eager"` and `fetchPriority="high"`, and never |
| 201 | + `loading="lazy"` — lazy-loading the LCP image reliably makes the score worse. |
| 202 | +- **Everything below the fold gets `loading="lazy"`.** |
| 203 | +- **Always render `width` and `height`** so the browser can reserve the box. The component does this |
| 204 | + from the largest variant; if you hand-roll, do it yourself. This is the entire fix for image-driven |
| 205 | + layout shift. |
| 206 | +- **Do not lazy-load images already in the viewport** on first paint — the request starts later than |
| 207 | + it needs to. |
| 208 | + |
| 209 | +## Failure modes |
| 210 | + |
| 211 | +| Symptom | Cause | Fix | |
| 212 | +| ---------------------------------------- | ------------------------------------------------- | ------------------------------------------- | |
| 213 | +| Thumbnails download 1024px files | `sizes` missing — browser assumed `100vw` | Write `sizes` to match the rendered width | |
| 214 | +| Both AVIF and WebP `<source>` emitted | `size` not selected in the GraphQL query | Add `size` to `variants` | |
| 215 | +| Images heavier after "upgrading" to AVIF | AVIF is usually larger here | Ship WebP; keep the original as fallback | |
| 216 | +| Some `srcset` URLs 404 | Width ladder hardcoded, or `_availableSizes` used | Build `srcset` from the returned `variants` | |
| 217 | +| Page jumps as images load | No `width`/`height` on the `<img>` | Render both; they set the aspect ratio | |
| 218 | +| Poor LCP on the product page | Hero is `loading="lazy"` | `eager` + `fetchPriority="high"` | |
| 219 | +| Image bill higher than expected | Images routed through a framework optimizer | Serve straight from `media.crystallize.com` | |
| 220 | +| `alt` is empty everywhere | `altText` not selected, or not authored | Query `altText`; author it in the PIM | |
| 221 | + |
| 222 | +## Related skills |
| 223 | + |
| 224 | +[[query]] covers the Catalogue and Discovery queries these fields come from, and |
| 225 | +[[content-model]] covers the Images component itself — including that product variant images are |
| 226 | +built-in rather than components. [[mutation]] covers uploading images and setting `altText`. |
0 commit comments