Skip to content

Commit d5c45de

Browse files
papelipeclaude
authored andcommitted
feat(skills): add responsive-images skill
Nothing in the skills covered rendering images. Two passing sentences said Crystallize serves "responsive sizes" in WebP/AVIF; neither said how to use them. Building a srcset is close to the first thing a frontend developer does with Crystallize media, and no skill said how. New skill `responsive-images`, plus references/without-react.md for non-React frontends. Three findings drive the guidance, all measured rather than assumed: WebP beats AVIF here, nearly always. Across 347 same-width comparisons on two unrelated production tenants, WebP produced the smaller file 343 times, averaging 25-30% smaller; on one tenant AVIF averaged larger than the original JPEG. AVIF wins at equal visual quality in general, but payload is decided by file size at the quality the encoder actually targeted. So: ship one modern format, not both — two <source> elements only guarantee the browser takes the first it supports, which is the larger file. The width ladder is per-image. Widths are drawn from 100/200/500/768/1024/ 1366/1600/1920/3200, but capped at the source image's own width: a 1200px original yields five sizes, a 500px original three. Eight distinct width sets appeared across 80 images. Hardcoding the ladder — or using the component's _availableSizes, which synthesizes URLs from the @{width} scheme — emits URLs that 404. Querying `size` is load-bearing. The reactjs-components Image compares the first AVIF and WebP variants to decide whether AVIF is worth emitting. Omit `size` from the query and the comparison cannot run, so it ships both formats — the more expensive outcome, silently. Also documented: why not to route these URLs through next/image or another framework optimizer (the variants are already static CDN files, so re-optimizing yields no smaller file while billing for the transform and the egress); that `sizes` has no default and omitting it makes the browser assume 100vw; the component's <figure>/<figcaption> wrapper; LCP and CLS handling; and focalPoint for cropping. Verified against @crystallize/reactjs-components v5.0.0 source and live Catalogue/Discovery responses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7d688b7 commit d5c45de

4 files changed

Lines changed: 365 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ bun type-check # TypeScript type checking
5757

5858
Skills are plain markdown files in `use-crystallize/skills/` — no build step required. Each skill has a `SKILL.md` with YAML frontmatter and an optional `references/` directory with supporting docs.
5959

60-
Available skills: `content-model`, `data-creation`, `information-architecture`, `js-api-client`, `mass-operations`, `mutation`, `permissions`, `plugins`, `pricing`, `query`, `taxonomy`, `vector-ranking` — the directory itself is the authoritative list.
60+
Available skills: `content-model`, `data-creation`, `information-architecture`, `js-api-client`, `mass-operations`, `mutation`, `permissions`, `plugins`, `pricing`, `query`, `responsive-images`, `taxonomy`, `vector-ranking` — the directory itself is the authoritative list.
6161

6262
## Using the Claude Plugin
6363

‎use-crystallize/skills/content-model/references/components.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Choosing the right component for each piece of **additional** data determines ho
4242
| Product demos, tutorials, promo videos | **Videos** |
4343
| Downloadable files (PDFs, ZIPs, etc.) | **Files** |
4444

45-
- **Images** → auto-transcoded to responsive sizes (Avif, WebP), served via Crystallize CDN. Supports multiple images per component (galleries, carousels). Each image gets `url`, `variants` (with width/height/size), `altText`, `caption`. Note: product variant images are built-in — use an Images component for product-level lifestyle photos, document hero images, or other non-variant media.
45+
- **Images** → auto-transcoded to responsive sizes (Avif, WebP), served via Crystallize CDN. See [[responsive-images]] for rendering them efficiently — `srcset`, `sizes`, format choice and why not to use a framework image optimizer. Supports multiple images per component (galleries, carousels). Each image gets `url`, `variants` (with width/height/size), `altText`, `caption`. Note: product variant images are built-in — use an Images component for product-level lifestyle photos, document hero images, or other non-variant media.
4646
- **Videos** → auto-transcoded for web/mobile streaming, CDN-delivered. Upload or embed.
4747
- **Files** → upload files for download. Use for manuals, datasheets, certificates, whitepapers.
4848

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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`.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Responsive Crystallize Images Without React
2+
3+
The `@crystallize/reactjs-components` `Image` component is React-only. Everywhere else — Svelte, Vue,
4+
Astro, Remix loaders emitting plain HTML, email templates, server-rendered templates — build the same
5+
markup by hand. This page is the fallback, not the recommendation: if you are in React, use the
6+
component.
7+
8+
## What you are reproducing
9+
10+
```html
11+
<picture>
12+
<source type="image/webp" srcset="…400w, …800w, …1200w" sizes="…" />
13+
<source type="image/jpeg" srcset="…400w, …800w, …1200w" sizes="…" />
14+
<img src="…fallback.jpeg" alt="…" width="1200" height="800" loading="lazy" />
15+
</picture>
16+
```
17+
18+
Four things make it correct:
19+
20+
1. One `<source>` per format, **modern format first** — the browser takes the first it supports.
21+
2. `srcset` entries carry `w` descriptors matching the file's real pixel width.
22+
3. `sizes` is present on every `<source>` **and** describes the rendered width, not the file width.
23+
4. `<img>` carries `src` (fallback), `alt`, `width`, `height`.
24+
25+
## Building it from the API response
26+
27+
```ts
28+
type Variant = { url: string; width: number; height?: number; size?: number };
29+
30+
const extOf = (url: string) => url.split(".").pop()!.toLowerCase();
31+
32+
const srcsetFor = (variants: Variant[], ext: string) =>
33+
variants
34+
.filter((v) => extOf(v.url) === ext)
35+
.sort((a, b) => a.width - b.width)
36+
.map((v) => `${v.url} ${v.width}w`)
37+
.join(", ");
38+
```
39+
40+
**Build from the variants the API returned.** The width ladder is capped at the source image's own
41+
width, so it differs per image — a hardcoded list produces URLs that 404.
42+
43+
### Choosing the modern format
44+
45+
Ship **one** modern format. Two `<source>` elements do not make the page faster; the browser takes the
46+
first it supports, and on Crystallize's current transcoder output that is usually the larger file.
47+
48+
```ts
49+
/** Pick the smaller of webp/avif at a common width; default to webp. */
50+
function pickModernFormat(variants: Variant[]): "webp" | "avif" {
51+
const at = (ext: string) =>
52+
variants.filter((v) => extOf(v.url) === ext && v.size).sort((a, b) => a.width - b.width);
53+
const [webp] = at("webp");
54+
const [avif] = at("avif");
55+
if (!webp?.size || !avif?.size) return "webp";
56+
return avif.size < webp.size ? "avif" : "webp";
57+
}
58+
```
59+
60+
This mirrors what the React component does. It needs `size` in the query — without it, default to
61+
`webp`, which wins the large majority of the time.
62+
63+
The original format is whatever the source was, `jpeg` or `png`. Derive it rather than assuming:
64+
65+
```ts
66+
const originalExt = (image: { url: string }) => {
67+
const ext = extOf(image.url);
68+
return ext === "jpg" ? "jpeg" : ext;
69+
};
70+
```
71+
72+
### Putting it together
73+
74+
```ts
75+
function pictureHtml(image: { url: string; altText?: string; variants: Variant[] }, sizes: string) {
76+
const modern = pickModernFormat(image.variants);
77+
const original = originalExt(image);
78+
const biggest = [...image.variants].sort((a, b) => b.width - a.width)[0];
79+
80+
return `
81+
<picture>
82+
<source type="image/${modern}" srcset="${srcsetFor(image.variants, modern)}" sizes="${sizes}">
83+
<source type="image/${original}" srcset="${srcsetFor(image.variants, original)}" sizes="${sizes}">
84+
<img src="${image.url}" alt="${image.altText ?? ""}"
85+
width="${biggest?.width ?? ""}" height="${biggest?.height ?? ""}" loading="lazy">
86+
</picture>`;
87+
}
88+
```
89+
90+
Escape `altText` for the target context — it is author-entered content, not a literal.
91+
92+
## Art direction: a different crop per breakpoint
93+
94+
`sizes` picks a _size_. When you need a different _image_ — a tall portrait crop on mobile, a wide one
95+
on desktop — that is `media`, and it takes separate `<source>` elements:
96+
97+
```html
98+
<picture>
99+
<source media="(max-width: 600px)" type="image/webp" srcset="…portrait srcset…" sizes="100vw" />
100+
<source type="image/webp" srcset="…landscape srcset…" sizes="(max-width: 1200px) 100vw, 1200px" />
101+
<img src="…" alt="…" width="1600" height="900" />
102+
</picture>
103+
```
104+
105+
Two different crops are two different images in the PIM. Do not try to fake art direction with
106+
`object-fit` alone — see focal point below for the case where one image is genuinely enough.
107+
108+
## Focal point
109+
110+
`focalPoint` (`{ x, y }`, normalized 0–1) is authored in the PIM and tells you where the subject is.
111+
It is what makes a single image survive being cropped to different aspect ratios:
112+
113+
```css
114+
.card-image {
115+
aspect-ratio: 4 / 3;
116+
object-fit: cover;
117+
object-position: var(--focal-x, 50%) var(--focal-y, 50%);
118+
}
119+
```
120+
121+
```ts
122+
const style = image.focalPoint ? `--focal-x:${image.focalPoint.x * 100}%;--focal-y:${image.focalPoint.y * 100}%` : "";
123+
```
124+
125+
Without it, `object-fit: cover` crops from the centre and decapitates people in portrait shots.
126+
127+
## Don't
128+
129+
- **Don't route these URLs through a framework image optimizer.** The variants are already static
130+
files on the CDN; re-optimizing yields no smaller file and bills you for the transform and the
131+
egress. See Rule 0 in SKILL.md.
132+
- **Don't omit `sizes`.** The browser then assumes `100vw` and downloads the largest candidate for a
133+
200px thumbnail.
134+
- **Don't put `sizes` only on the `<img>`.** Each `<source>` needs it too; `<img>`-only leaves the
135+
`<source>` elements defaulting to `100vw`.
136+
- **Don't lazy-load the LCP image.** The hero gets `loading="eager"` and `fetchpriority="high"`.
137+
- **Don't hardcode the width ladder.** It is capped per image at the source width.

0 commit comments

Comments
 (0)