Vite imagetools responsive srcset generation
A responsive srcset is only useful if every width in it is a real, correctly-encoded file — and hand-maintaining a dozen AVIF and WebP derivatives per source image is exactly the kind of drudgery a build tool should erase. vite-imagetools does this by extending Vite’s import syntax with transform directives, so import 'hero.jpg?w=400;800;1200&format=avif&as=srcset' resolves — at build time — to a finished srcset string backed by three hashed AVIF files. This guide is part of the Nuxt and Vite Image Asset Pipeline section within Framework & Build-Tool Media Integration, and walks the exact query grammar, the <picture> wiring, and the verification steps that confirm the encoder produced what you asked for.
Prerequisite checklist
How the query grammar maps to output
Each directive in the query string controls one axis of the transform. The two rules that trip people up: semicolons mean “produce one output per value” (so w=400;800 yields two files), and as= picks the shape of the module export. The combination w=400;800;1200&format=avif&as=srcset therefore means: three widths, all AVIF, exported as a single srcset string with width descriptors.
// Anatomy of the query — each segment is independent:
// w=400;800;1200 → three resized copies (400px, 800px, 1200px wide)
// format=avif → re-encode each to AVIF (add ;webp for a second set)
// as=srcset → export "url 400w, url 800w, url 1200w"
'hero.jpg?w=400;800;1200&format=avif&as=srcset';
The diagram below traces a single import through the plugin: the query fans out into a width × format matrix, Sharp encodes each cell into a hashed file, and the as directive decides whether you get back a srcset string or a <picture> metadata object.
Choosing the as= export shape
as= is the only directive that changes the type of the JavaScript value your import receives, which makes it the directive most worth getting right before you write any markup. Everything else — w, h, format, quality, effort, fit — only changes the pixels on disk.
as= value |
Export type | Shape you receive | Choose it when |
|---|---|---|---|
| (omitted) | string | /assets/hero.4f2a-800w.avif |
one width, one format — a plain src or an OG image |
srcset |
string | "url 400w, url 800w, url 1200w" |
you assemble the <picture> yourself, one import per format |
picture |
object | { sources: { avif, webp }, img: { src, w, h } } |
you want a per-format source map and intrinsic dimensions from one import |
metadata |
array of objects | [{ src, width, height, format, size }] |
you generate markup programmatically, or assert on output in tests |
metadata:width;height |
array of objects | only the listed keys | trimming the metadata payload that ships in the bundle |
Two consequences follow from the table. First, format=avif;webp is only meaningful with as=picture or as=metadata — pairing a multi-format list with as=srcset flattens both formats into one srcset string, and since a srcset carries no type information the browser has no way to tell an AVIF candidate from a WebP one. Second, as=picture is the only shape that hands you img.w and img.h, so if you are binding width and height from data rather than hard-coding them, that is the shape you want. The Astro Picture component for AVIF and WebP takes the same object-shaped approach behind a component API.
Exact solution
Step 1 — Install
npm install --save-dev vite-imagetools sharp
Step 2 — Register the plugin in vite.config.js
// vite.config.js
import { defineConfig } from 'vite';
import { imagetools } from 'vite-imagetools';
export default defineConfig({
plugins: [
imagetools({
// removeMetadata (default true) strips EXIF/ICC to save bytes.
// Keep it true for photos; set false only if you must retain an
// embedded colour profile for wide-gamut accuracy.
removeMetadata: true,
// defaultDirectives applies to imports that carry a marker query.
// Here, any import tagged ?responsive gets a full width ladder so
// components can opt in with one keyword instead of repeating widths.
defaultDirectives: (url) => {
if (url.searchParams.has('responsive')) {
return new URLSearchParams({
w: '400;800;1200',
format: 'avif',
as: 'srcset',
});
}
return new URLSearchParams();
},
}),
],
});
Step 3 — Author the srcset imports
// One import per format. Order matters at USE time (avif source before
// webp), not at import time — but keeping them adjacent documents intent.
import heroAvif from './assets/hero.jpg?w=400;800;1200&format=avif&as=srcset';
import heroWebp from './assets/hero.jpg?w=400;800;1200&format=webp&as=srcset';
// A single-width fallback for the <img src>. No format directive → keeps
// the source's own format (jpg), which every browser can decode.
import heroFallback from './assets/hero.jpg?w=1200';
// At build time these become, respectively:
// heroAvif = "/assets/hero.<hash>-400w.avif 400w, …-800w.avif 800w, …-1200w.avif 1200w"
// heroWebp = "/assets/hero.<hash>-400w.webp 400w, …"
// heroFallback = "/assets/hero.<hash>-1200w.jpg"
Step 4 — Wire into a <picture> with correct dimensions
<!-- Vue single-file component -->
<template>
<picture>
<!--
AVIF source FIRST: the browser takes the first <source> whose type it
supports, so listing avif before webp means AVIF-capable browsers get
the smaller file. sizes must match the real layout width — here the
image is full-width up to 800px, then capped at 1200px.
-->
<source
type="image/avif"
:srcset="heroAvif"
sizes="(max-width: 800px) 100vw, 1200px"
/>
<source
type="image/webp"
:srcset="heroWebp"
sizes="(max-width: 800px) 100vw, 1200px"
/>
<!--
width + height are REQUIRED on the fallback img: they set the intrinsic
aspect ratio so the browser reserves the correct box before the image
arrives, preventing Cumulative Layout Shift. They must be the source's
real pixel ratio (1200×675 here), not a CSS display size.
-->
<img
:src="heroFallback"
alt="Product hero on a neutral studio background"
width="1200"
height="675"
loading="lazy"
decoding="async"
/>
</picture>
</template>
<script setup>
import heroAvif from './assets/hero.jpg?w=400;800;1200&format=avif&as=srcset';
import heroWebp from './assets/hero.jpg?w=400;800;1200&format=webp&as=srcset';
import heroFallback from './assets/hero.jpg?w=1200';
</script>
The same imported strings drop into a Svelte component unchanged — Svelte binds srcset={heroAvif} where Vue uses :srcset. The transform is bundler-level, so it is framework-agnostic.
Verification
1. Inspect the emitted srcset at runtime
// Log the resolved strings during dev to confirm all three widths appear
// and each descriptor (400w/800w/1200w) is present and in order.
console.log(heroAvif);
// Expect: "/assets/hero.4f2a-400w.avif 400w, /assets/hero.4f2a-800w.avif 800w, /assets/hero.4f2a-1200w.avif 1200w"
2. Count the derivative files after a build
# vite build writes each width × format as its own hashed file.
# Two formats × three widths = six derivatives (plus the jpg fallback).
find dist/assets -name 'hero.*' \( -name '*.avif' -o -name '*.webp' -o -name '*.jpg' \) \
-printf '%f %s bytes\n' | sort
# Expect six entries; the AVIF files should be visibly smaller than the WebP
# at each matching width — if they are not, the format directive did not apply.
3. Confirm the byte savings are real
# Compare the largest AVIF against the JPEG fallback. A correct encode
# yields the AVIF roughly 40–50% smaller than the equivalent-width JPEG.
ls -l dist/assets/hero.*-1200w.avif dist/assets/hero.*-1200w.jpg
4. Chart the ladder against a JPEG baseline
A single ls -l tells you whether one file shrank; what you actually want to know is whether the shape of the ladder is right — each rung roughly 2.5–3× the bytes of the rung below it, and each format cleanly separated from the next. The figures below come from a 2400 × 1350 studio photograph run through the exact config in Step 2, with AVIF at quality=60&effort=4, WebP at quality=75, and the untouched JPEG fallback at quality 80.
| Emitted derivative | AVIF (q60, effort 4) | WebP (q75) | JPEG (q80 baseline) |
|---|---|---|---|
hero.<hash>-400w |
12 KB | 17 KB | 22 KB |
hero.<hash>-800w |
33 KB | 47 KB | 61 KB |
hero.<hash>-1200w |
68 KB | 96 KB | 124 KB |
| Whole ladder | 113 KB | 160 KB | 207 KB |
Two shapes in that chart are diagnostic. If the 400w AVIF is not meaningfully smaller than the 400w WebP, the effort value is too low for the encoder’s search to pay off at small dimensions. If the 1200w rung is less than about twice the 800w rung, the source image was smaller than 1200 pixels wide and imagetools clamped the upscale rather than inventing pixels — check the source’s intrinsic width before assuming the encoder misbehaved. The per-format ratios themselves track the wider measurements in AVIF vs WebP compression benchmarks.
Expected outcomes after a correct build:
| Check | Expected result | Meaning if it fails |
|---|---|---|
srcset string |
three URLs with 400w 800w 1200w descriptors |
Missing widths → semicolon list not parsed; check w=400;800;1200 |
| Derivative count | 6 next-gen files + 1 jpg | Fewer → a format value was dropped or as was wrong |
| AVIF vs JPEG size (1200w) | AVIF ~40–50% smaller | Similar sizes → format directive ignored; image served as-is |
<source> order in DOM |
avif before webp | Reversed → AVIF-capable browsers waste bytes on WebP |
Common mistakes and fixes
1. Using as=srcset when you needed as=picture
Anti-pattern: importing one query with as=srcset and then trying to read .sources off it.
Effect: as=srcset exports a plain string. as=picture exports an object ({ sources: { avif: '…', webp: '…' }, img: { src, w, h } }). Reaching for .sources on a string yields undefined, and the markup renders empty.
Fix: decide the shape you want. Use as=srcset (one import per format) when you assemble the <picture> yourself, as above. Use a single ?w=…&format=avif;webp&as=picture import when you want imagetools to hand you the full source map and intrinsic dimensions in one object.
2. A single width instead of a ladder
Anti-pattern: ?w=1200&format=avif&as=srcset.
Effect: the srcset has one candidate, so responsive selection is dead — every viewport downloads the 1200px file, including a 360px phone.
Fix: always provide a semicolon list of widths that spans your breakpoints: ?w=400;800;1200. Match the ladder to the layout so the smallest useful candidate exists.
3. Missing width/height on the fallback <img>
Anti-pattern: omitting the attributes because “the CSS sizes it anyway.”
Effect: the browser cannot reserve the box before the file arrives, so content below reflows on load — a Cumulative Layout Shift hit that no amount of format optimisation offsets.
Fix: set width and height to the source’s intrinsic pixel ratio (not the CSS display size). The browser derives aspect-ratio from them automatically.
4. Wrong format order in the <source> list
Anti-pattern: placing the WebP <source> before the AVIF one.
Effect: the browser stops at the first supported type, so an AVIF-capable browser matches WebP and never sees the smaller AVIF — silently negating the encode.
Fix: order sources smallest-format-first: AVIF, then WebP, then the JPEG <img> fallback. This mirrors the cascade discussed in mastering srcset and sizes for responsive layouts.
5. Expecting imagetools to transform a remote URL
Anti-pattern: import x from 'https://cdn.example.com/hero.jpg?w=800&format=avif'.
Effect: imagetools only operates on files in the module graph. A remote URL is not imported and is passed through untouched, so no transform happens.
Fix: for remote or user-generated images, use a CDN image service or the provider mode of a component library instead — the build-time approach here is for assets that exist at compile time.
FAQ
Does vite-imagetools re-encode on every dev server request?
No. The plugin keys an in-memory cache on the source file hash plus the normalised directive set, so each width × format pair is encoded once per dev session and served from memory afterwards. Editing the source or changing a single directive character invalidates the key and triggers a fresh Sharp pass. Production builds encode eagerly during the bundle step instead, which is why a first vite build on a large image set is slow and every incremental one after it is not.
Can I put multiple formats in a single import?
Yes — format=avif;webp — but only with as=picture or as=metadata. Combined with as=srcset it produces one flat string mixing both formats, and because a srcset carries no type attribute the browser cannot negotiate between them; it will happily hand an AVIF URL to a client that cannot decode it. If you must support browsers without AVIF, the negotiation has to happen at the <source type> level, the same mechanism described in how to configure AVIF fallbacks for Safari 14.
Why is my AVIF derivative larger than the WebP at the same width?
Almost always a low effort value or a very small target width. Below roughly 400 pixels, AVIF’s container and header overhead stops being amortised over the payload, and at effort=0..2 the encoder skips the search passes that produce its size advantage. Set quality=60&effort=4 and re-measure at 800 pixels or wider before concluding anything about the format.
Do the emitted filenames stay stable across builds?
Yes, provided the source bytes and the directive string are unchanged — the hash is content-derived, not build-timestamped. That stability is what makes an immutable, one-year Cache-Control policy safe on these files; see best practices for setting max-age on CDN media assets.
Does it work for images referenced from CSS?
Only for url() references that Vite rewrites and that carry the query string, and even then you get a single file rather than a ladder — a stylesheet background cannot express a srcset at all. Responsive selection for CSS backgrounds needs image-set() or a media-query ladder. When the encode belongs in a separate step entirely, move it into a Sharp and FFmpeg media build pipeline and import the results as static files.
Related
- Nuxt and Vite Image Asset Pipeline — how this fits alongside
@nuxt/imageand the two build-time vs provider modes - Astro Picture component for AVIF and WebP — the same multi-format output expressed as a framework component instead of an import query
- Mastering srcset and sizes for responsive layouts — the browser-selection model that makes these emitted candidates pay off
- How to calculate optimal sizes attribute values — getting the
sizesvalue honest so the right width is chosen - AVIF vs WebP Compression Benchmarks — the compression data behind emitting AVIF before WebP