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

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

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.