Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ bun type-check # TypeScript type checking

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.

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.
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.

## Using the Claude Plugin

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Choosing the right component for each piece of **additional** data determines ho
| Product demos, tutorials, promo videos | **Videos** |
| Downloadable files (PDFs, ZIPs, etc.) | **Files** |

- **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.
- **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.
- **Videos** → auto-transcoded for web/mobile streaming, CDN-delivered. Upload or embed.
- **Files** → upload files for download. Use for manuals, datasheets, certificates, whitepapers.

Expand Down
226 changes: 226 additions & 0 deletions use-crystallize/skills/responsive-images/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
---
name: responsive-images
description: >
Render Crystallize images efficiently on the web — srcset, sizes, picture, format choice, lazy
loading, LCP and CLS. Use when building product galleries, hero images, thumbnails, grids or any
page that displays Crystallize media; when images are too heavy, the page scores badly on
Lighthouse or Core Web Vitals, or the wrong image size is being downloaded; when choosing between
AVIF, WebP and the original format; when deciding whether to use next/image or another framework
image component with Crystallize; or when querying image variants from the Catalogue or Discovery
API. Trigger on "srcset", "sizes", "responsive images", "picture element", "image optimization",
"image variants", "media.crystallize.com", "webp", "avif", "lazy loading", "fetchpriority",
"layout shift", "CLS", "LCP", "next/image", "image component", "@crystallize/reactjs-components",
"thumbnail", "image payload", "images too big".
metadata:
author: Crystallize
version: "1.0"
---

# 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`

```graphql
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:

```graphql
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:

```tsx
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](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:

```html
<!-- 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 |

## 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`.
137 changes: 137 additions & 0 deletions use-crystallize/skills/responsive-images/references/without-react.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# 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

```html
<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

```ts
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.

```ts
/** 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:

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

### Putting it together

```ts
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:

```html
<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:

```css
.card-image {
aspect-ratio: 4 / 3;
object-fit: cover;
object-position: var(--focal-x, 50%) var(--focal-y, 50%);
}
```

```ts
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.