Image metadata
One upload-time decode gives every render a blur placeholder, a color palette, alpha flags, and a subject-aware crop — no extra files, no extra requests.
Every upload gets one Sharp decode that stamps a render's needs onto the doc: a ladder of placeholder tiers, a color palette, alpha flags, and a crop that stays on the subject.
Reads pull these straight off the document. No extra files, no extra requests.
This page walks each field and how reads consume it.
Placeholders
A blurred preview paints from the first render and is covered when the real image loads. No pop-in.
The upload hook stores the image as a ladder of placeholder tiers — two formats behind one quality knob (why below). The low tiers are BlurHash strings; the top two are a micro-webp:
| Tier | Stored as | Size | Looks like |
|---|---|---|---|
xs | blurhash 2×2 | 12 chars | a corner-to-corner gradient |
sm | blurhash 4×3 | 28 chars | the classic blurhash (the default) |
md | blurhash 6×4 | 52 chars | soft composition blobs |
lg | blurhash 8×6 | 100 chars | clear composition |
xl | blurhash 9×9 | 166 chars | maximum blurhash detail — still very much a blur |
2xl | webp 32px | ~0.5–1 KB | shapes and light read clearly |
3xl | webp 64px | ~1–3 KB | a heavily blurred thumbnail |
The Focus & crop component's half & half preview shows any tier against the real pixels,
cropped to the same render. sm — 28 characters — reads as pure atmosphere:
Half & half preview at the sm tier: real image left, the 28-character blurhash placeholder right, both cropped to the same 3:2 render
3xl — a ~1–3 KB micro-webp — already carries the composition:
Half & half preview at the 3xl tier: real image left, the 64px micro-webp placeholder right, the lighthouse clearly legible
Why two formats
Neither format spans the whole quality range. Their strengths are opposites, so the ladder switches codecs at the point where one stops earning its bytes.
- BlurHash is performance-shaped. A placeholder is a handful of cosine components, not pixels — 12–166 characters that render at any size and crop in coefficient space (pure string math, microseconds, no image decode). That's also its ceiling: past ~9×9 components, extra bytes buy almost no extra detail. Blurhash never resolves beyond soft blobs, no matter how much you spend.
- webp is quality-shaped. Real pixels carry real composition — at 64px the subject is already
legible. But that costs real bytes:
2xlruns ~0.5–1 KB and3xl~1–3 KB, versus tens of characters for a blurhash. Every read that declares a webp-tier blur crops it with a Sharp decode on your server (milliseconds, not microseconds — the placeholder virtual does the crop per read). Stretched over the render box, the browser's upscaling smooths the micro-image into the blur you see.
So the ladder runs blurhash from xs to xl — its 9×9 best — and switches to micro-webp for
2xl/3xl, exactly where more quality stops being possible in blurhash. Consumers never pick a
codec: one quality knob, and the tier you ask for determines the format, its cost, and its crop
path. Reads that want near-free placeholders stay in the blurhash tiers; reads that want a
recognizable preview pay for a micro-webp — 2xl ~0.5–1 KB, 3xl ~1–3 KB. That's a choice per
read, not per project.
The placeholder virtual
On read, the virtual placeholder field does all the placeholder work in the field hook, next
to the data. Consumers select one field and paint what they get back.
The field is on by default: an undeclared read gets the sm tier, cropped to any declared
render ratio — except for alpha/SVG docs, which never cover a placeholder and so skip the implicit
default. context: { blur: false } opts any read out entirely. Declaring a blur picks the
tier: blurhash tiers crop in coefficient space (pure string math, microseconds); webp tiers
crop the stored micro-image (a ~1 KB Sharp decode, milliseconds). It's the same crop the transform
endpoint makes, applied to the placeholder — no files, no extra cache rows.
Declare the blur with a read context:
const image = await payload.findByID({
collection: 'images',
id,
depth: 0,
select: { alt: true, width: true, height: true, placeholder: true, variantVersion: true },
context: { image: { aspectRatio: '16:9' }, blur: { quality: 'sm' } }, // placeholder arrives as a finished data URI
})(imageFor(...).blur('sm') declares the same thing for
you.)
The context keys — and their REST-header equivalents on a X-Blurhash read — are:
context key | X-Blurhash header | Values | Meaning |
|---|---|---|---|
blur.quality | q= | xs sm md lg xl 2xl 3xl — default sm | which placeholder tier to render |
image.aspectRatio | ratio prefix (e.g. 16:9) | W:H | crop the placeholder to this ratio |
A read that declares a blur with no tier defaults to sm, as does a read that declares nothing.
Only context: { blur: false } returns null — for reads on surfaces that would never paint a
placeholder. A doc with no stored tiers renders without a placeholder. The field's return type is
string | null.
<ResponsiveImage> is passive here: it paints the string it was handed as its own
background-image — zero network, zero client JS, native swap when the real pixels load.
Non-React consumers get the same placeholder: send X-Blurhash: 16:9; q=md on a REST read
and placeholder comes back as a data URI cropped to that ratio at that tier (16:9 maps to
image.aspectRatio, q=md to blur.quality). Decoding client-side with a stock blurhash
library instead? Add format=hash and the cropped raw hash string comes back (blurhash tiers
only — webp tiers degrade to xl).
Palette & alpha
The same decode extracts a Sanity-style color palette and alpha flags, stored on the doc and riding through reads and relationship population:
{
"palette": {
"dominant": { "background": "#8a6748", "foreground": "#ffffff", "title": "#ffffff", "population": 0.31 },
"vibrant": { "background": "#d97a2b", "foreground": "#ffffff", "title": "#ffffff", "population": 0.08 },
"muted": null, "darkVibrant": null, "darkMuted": null,
"lightVibrant": null, "lightMuted": null // same swatch shape when present; null when nothing matches
},
"hasAlpha": false, // the file carries an alpha channel
"isOpaque": true // no sampled pixel is actually transparent
}Use palette.dominant.background as a solid-color placeholder behind tiny thumbs (cheaper than a
blurhash PNG), foreground/title for contrast-safe text over the image, and the alpha flags for
format decisions (e.g. don't JPEG-flatten a transparent logo).
Focal point, hotspot & crop
Optimization is only half the job: a 16:9 hero and a 1:1 thumbnail of the same photo need different crops, and a naive center-crop lops off heads.
One square upload rendered at natural, 16:9, 1:1, and 9:16 — the focal point (28%/72%, on the apple) keeps the subject in frame at every ratio
Three stored, non-destructive layers (Sanity's model) shape every crop. The original file is never modified, unlike Payload's built-in destructive "Edit image" crop:
| Layer | Stored as | Meaning |
|---|---|---|
| Crop | cropLeft/Top/Right/Bottom (%) | "only this region may ever be used" — everything outside is trimmed at transform time |
| Focal point | focalX/focalY (%) | crop windows center here |
| Hotspot size | focalSize (%) | the circle's diameter: how much context must stay in frame — smaller = tighter zoom, 100 = the classic maximal window |
- Set automatically, refined once. On a fresh upload with no editor-set focal, the hook sets
focalX/focalYfrom Sharp's saliency (attention) analysis when that analysis yields a confident attention point — one comfortably inside the frame. Low-saliency images (flat colors, smooth gradients) report edge-pinned points, which are discarded rather than guessed, so the focal stays at Payload's default (50/50). The Focus & crop component does the rest: click/drag sets the focal point, the circle's edge handle sizes the hotspot, the corner handles draw the crop (everything outside dims). Ratio tiles preview the real crop, the placeholder, or half & half — live, with the exact geometry the endpoint uses. - It rides along automatically. The endpoint reads all three layers from the document on any
fit=coverrequest that pins both dimensions (w+h— which every ratio-declared render does), so<ResponsiveImage>, the virtual URLs, raw endpoint calls, and theplaceholdervirtual all honor them with nothing extra to pass. A width-only cover request and non-cover fits honor the crop rect but resize without the focal window. - No upscaling, even zoomed. A tight hotspot crops a small pixel region; the endpoint serves that region at its native resolution (CSS scales it up) rather than inventing pixels.
- Change any layer and stale renders refresh. Edits purge that image's cached variants and bump
its
vtoken, so anything already served refetches — Caching has the full story.
The Focus & crop component: the hotspot circle with its zoom handle over the image, crop corner handles, and live ratio preview tiles with normal/placeholder modes
Backfilling an existing library
All of the above is stamped by the upload hook. Images uploaded before the plugin (or before a feature) carry none of it. Reads degrade gracefully — no placeholder, no palette, nothing breaks — until you stamp them once with the bundled command:
payload images:backfill # only images missing metadata (idempotent)
payload images:backfill --force # regenerate every image
payload images:backfill --focal # also set the saliency focal on docs still at 50/50
payload images:backfill --collection media # a renamed images collection--focal is opt-in because it changes crops on a live site (busts caches, regenerates
variants); everything else is additive metadata that alters no rendered pixels.