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.

vite-imagetools query to srcset expansion A query import with widths 400, 800, 1200 and format avif is expanded into a matrix of three widths, each resized and re-encoded by Sharp into a hashed AVIF file, then joined by the as=srcset directive into a single srcset string with width descriptors. import query ?w=400;800;1200 &format=avif&as=srcset resize 400w Sharp → avif resize 800w Sharp → avif resize 1200w Sharp → avif hero.4f2a-400w.avif hero.4f2a-800w.avif hero.4f2a-1200w.avif as=srcset "…400w, …800w, …1200w" add ;webp to format → a second parallel matrix

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.

Choosing the as= directive A decision tree with three questions. If only one output width is needed the export is as=url. If multiple widths are needed with one import per format the export is as=srcset. If all formats come from one import, needing intrinsic width and height leads to as=picture, otherwise as=metadata. more than one output width? no yes as=url (default) one hashed file path one import per format? yes no as=srcset "…400w, …800w" string for a hand-built <picture> need intrinsic width and height? yes no as=picture { sources, img } as=metadata array of records Dashed nodes are questions; solid nodes are what the import evaluates to at build time.

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
Derivative byte sizes across the width ladder Grouped bars comparing AVIF, WebP and JPEG file sizes at three widths. At 400 pixels the files are 12, 17 and 22 kilobytes; at 800 pixels 33, 47 and 61 kilobytes; at 1200 pixels 68, 96 and 124 kilobytes, so AVIF stays about 45 percent smaller than JPEG at every width. AVIF WebP JPEG q80 0 50 KB 100 KB 12 17 22 33 47 61 68 96 124 400w 800w 1200w AVIF holds a ~45% byte advantage at every rung: 113 KB for the whole ladder against 207 KB of JPEG.

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.