Skip to content

Responsive Images ​

srcset + sizes ​

Pass widths to auto-generate the srcset attribute:

vue
<VImage
  src="/photo.jpg"
  alt="Photo"
  :widths="[400, 800, 1200]"
  sizes="(max-width: 768px) 100vw, 50vw"
/>

Renders:

html
<img
  src="/photo.jpg"
  srcset="/photo.jpg 400w, /photo.jpg 800w, /photo.jpg 1200w"
  sizes="(max-width: 768px) 100vw, 50vw"
  alt="Photo"
/>

When widths is not provided, srcset is not added — the plain src is used. When widths is provided but sizes is not, sizes defaults to "100vw".

Density descriptors (1x / 2x / 3x) ​

For fixed-size images — icons, avatars, logos — use densities instead of widths. The browser picks the candidate matching the device pixel ratio; no sizes is needed. densities takes precedence over widths (the two descriptor types can't be mixed in one srcset).

:densities accepts two forms:

vue
<!-- 1. Per-density URL map — distinct files (recommended for static assets). -->
<VImage
  src="/avatar.png"
  alt="Avatar"
  :width="48"
  :height="48"
  :densities="{ 1: '/avatar.png', 2: '/avatar@2x.png', 3: '/avatar@3x.png' }"
/>
<!-- → srcset="/avatar.png 1x, /avatar@2x.png 2x, /avatar@3x.png 3x" -->

<!-- 2. Density list — reuses the single `src` for every density. Only useful
     when the URL itself is resolution-aware (a CDN/DPR endpoint). -->
<VImage src="https://cdn.example.com/avatar?dpr=auto" alt="Avatar" :densities="[1, 2, 3]" />
<!-- → srcset="…?dpr=auto 1x, …?dpr=auto 2x, …?dpr=auto 3x" -->

Using the utilities directly:

ts
import { generateSrcset, generateSizes, generateDensitySrcset } from '@macrulez/vue-image-kit'

generateSrcset('/photo.jpg', [400, 800, 1200])
// → '/photo.jpg 400w, /photo.jpg 800w, /photo.jpg 1200w'

generateSizes('(max-width: 768px) 100vw, 50vw')
// → '(max-width: 768px) 100vw, 50vw'

generateSizes()
// → '100vw'

generateDensitySrcset('/logo.png', [1, 2, 3])
// → '/logo.png 1x, /logo.png 2x, /logo.png 3x'

// Distinct files per density via a URL map:
generateDensitySrcset({ 1: '/a.png', 2: '/a@2x.png' }, [1, 2])
// → '/a.png 1x, /a@2x.png 2x'

WebP / AVIF source switching ​

When src is an object instead of a string, <VImage> renders a <picture> element with the appropriate <source> elements:

vue
<VImage
  :src="{
    avif: '/photo.avif',
    webp: '/photo.webp',
    fallback: '/photo.jpg',
  }"
  alt="Photo"
  :width="1200"
  :height="800"
/>

Renders:

html
<picture>
  <source srcset="/photo.avif" type="image/avif" />
  <source srcset="/photo.webp" type="image/webp" />
  <img src="/photo.jpg" alt="Photo" width="1200" height="800" />
</picture>

The browser picks the first format it supports. If only webp is provided, only one <source> is added. fallback is always required.

SrcSet object ​

ts
interface SrcSet {
  avif?: string // URL of the AVIF version
  webp?: string // URL of the WebP version
  fallback: string // Required — the original format (JPEG/PNG)
}

Responsive sources — art direction ​

Use this when you need to serve a fundamentally different image (different crop, different composition) based on screen size. Implemented via named breakpoints — the browser picks the first matching <source media="...">.

Global breakpoints (set once when installing the plugin) ​

ts
// main.ts
app.use(VImageKitPlugin, {
  breakpoints: {
    sm: '(max-width: 640px)',
    md: '(max-width: 1024px)',
    lg: '(min-width: 1025px)',
  },
})

Using in components — keys only ​

vue
<VImage
  src="/hero-desktop.jpg"
  alt="Hero"
  :sources="{
    sm: '/hero-mobile.jpg',
    md: '/hero-tablet.jpg',
  }"
/>

Generates:

html
<picture>
  <source media="(max-width: 640px)" srcset="/hero-mobile.jpg" />
  <source media="(max-width: 1024px)" srcset="/hero-tablet.jpg" />
  <img src="/hero-desktop.jpg" alt="Hero" />
</picture>

<source> order is set automatically in ascending max-width order — required by <picture>, which picks the first matching source.

Per-component breakpoints ​

Merged with global breakpoints. Local keys take priority on conflict:

vue
<VImage
  src="/product-desktop.jpg"
  alt="Product"
  :breakpoints="{
    xs: '(max-width: 375px)',
    wide: '(min-width: 1600px)',
  }"
  :sources="{
    xs: '/product-xs.jpg',
    sm: '/product-mobile.jpg',
    md: '/product-tablet.jpg',
    wide: '/product-wide.jpg',
  }"
/>

The resulting <picture> contains <source> elements for xs, sm, md (from merged breakpoints), and wide — sorted automatically.

Combining with AVIF/WebP ​

Responsive sources (sources) and format sources (src as object) are independent and rendered together:

vue
<VImage
  :src="{ avif: '/hero.avif', webp: '/hero.webp', fallback: '/hero.jpg' }"
  :sources="{ sm: '/hero-mobile.jpg' }"
  alt="Hero"
/>
html
<picture>
  <source media="(max-width: 640px)" srcset="/hero-mobile.jpg" />
  <source srcset="/hero.avif" type="image/avif" />
  <source srcset="/hero.webp" type="image/webp" />
  <img src="/hero.jpg" alt="Hero" />
</picture>

That covers "one crop set + one format set, independent of each other." For a different crop and different formats per breakpoint — e.g. a portrait AVIF/WebP crop on mobile, a landscape AVIF/WebP crop on desktop — a breakpoint's value in sources can itself be a { avif?, webp?, fallback } object instead of a plain URL:

vue
<VImage
  alt="Hero"
  :sources="{
    sm: { avif: '/hero-mobile.avif', webp: '/hero-mobile.webp', fallback: '/hero-mobile.jpg' },
    md: { webp: '/hero-tablet.webp', fallback: '/hero-tablet.jpg' },
  }"
  src="/hero-desktop.jpg"
/>
html
<picture>
  <source media="(max-width: 640px)" srcset="/hero-mobile.avif" type="image/avif" />
  <source media="(max-width: 640px)" srcset="/hero-mobile.webp" type="image/webp" />
  <source media="(max-width: 640px)" srcset="/hero-mobile.jpg" />
  <source media="(max-width: 1024px)" srcset="/hero-tablet.webp" type="image/webp" />
  <source media="(max-width: 1024px)" srcset="/hero-tablet.jpg" />
  <img src="/hero-desktop.jpg" alt="Hero" />
</picture>

Plain-URL and format-object breakpoints can be mixed freely in the same sources object. avif/webp are both optional per breakpoint — only the formats you actually have are emitted.

Per-breakpoint sizes — no layout shift on switch ​

A different crop for a different breakpoint usually also means a different aspect ratio — a tall portrait crop on desktop, a wide landscape crop on tablet. Without telling <VImage> about that, it can only reserve space using the root width/height, so the placeholder (and the blurhash preview, if you're using one) show the wrong proportions on any breakpoint whose crop doesn't match the root image — and the box visibly jumps once the real photo loads.

Give a sources entry its own width/height to fix that:

vue
<VImage
  src="/desktop-portrait.jpg"
  :width="720"
  :height="1237"
  :sources="{
    tablet: { src: '/tablet-landscape.jpg', width: 1400, height: 700 },
  }"
  :breakpoints="{ tablet: '(max-width: 1024px)' }"
  alt="Hero"
/>

With width/height on the tablet entry, <VImage> tracks which breakpoint's media query is currently active (reactively — it updates if you resize past the breakpoint) and reserves the placeholder box, and the blurhash decode, at that breakpoint's own aspect ratio instead of always falling back to the root image's. The matching <source> also renders with real width/height attributes, so once the browser picks it, the loaded image keeps that same reserved space — no jump between the placeholder and the final photo, on any breakpoint.

width and height are both-or-nothing per entry: give only one and it's dropped (with a console warning in development), rather than risk a distorted box.

A sources entry also accepts an ImageMeta object directly — the shape produced by the CLI/Vite-plugin manifest or a ?vik build-time import — so per-breakpoint metadata generated at build time can be passed straight through without picking it apart first:

vue
<script setup>
import tabletMeta from '/tablet-landscape.jpg?vik'
</script>

<template>
  <VImage
    src="/desktop-portrait.jpg"
    :width="720"
    :height="1237"
    :sources="{ tablet: tabletMeta }"
    :breakpoints="{ tablet: '(max-width: 1024px)' }"
    alt="Hero"
  />
</template>

Entries without width/height keep working exactly as before — this is purely additive.

Per-breakpoint placeholders ​

A placeholder has the same problem as the box. A blurhash of the tall root photo, stretched into a wide tablet box, puts the colors in the wrong places. A sources entry can carry its own placeholder next to src — hazehash, blurhash, thumbhash, placeholder (an LQIP data URL) or placeholderColor:

vue
<VImage
  src="/desktop-portrait.jpg"
  :width="720"
  :height="1237"
  blurhash="LEHV6nWB2yk8pyo0adR*.7kCMdnj"
  :sources="{
    tablet: {
      src: '/tablet-landscape.jpg',
      width: 1400,
      height: 700,
      blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
    },
  }"
  :breakpoints="{ tablet: '(max-width: 1024px)' }"
  alt="Hero"
/>

While an entry's media query matches, <VImage> shows that entry's placeholder, at that entry's proportions, instead of the root image's.

  • The matching entry replaces the root's whole placeholder set — it isn't merged field by field. A root placeholderColor doesn't win over a source's blurhash, and a root blurhash doesn't show behind a source that only has a placeholderColor.
  • An entry without a placeholder of its own, and a breakpoint where no entry matches, use the root's. placeholderMode applies to every breakpoint.
  • No media query is evaluated on the server, so the server-rendered markup is always the root image's.
  • An ImageMeta entry (a ?vik import, as in the example above) brings its own placeholder along.
  • An entry that is a plain { avif, webp, fallback } object can't carry a placeholder; wrap it as { src: { avif, webp, fallback }, blurhash }.

With a placeholders manifest registered, the entry for the source's src fills in the same fields — hazehash, blurhash, thumbhash, color, width and height — wherever the source doesn't set them itself. npx vue-image-kit placeholders can generate both forms: see Art-direction sources.

BreakpointMap ​

ts
type BreakpointMap = Record<string, string>
// key — arbitrary name, value — CSS media query

Breakpoint priority ​

  • Local breakpoints prop on the component — high priority, overrides global keys on conflict.
  • Global breakpoints from VImageKitPlugin — base, available in all components.

This merge, along with sorting sources into ordered <source> entries, is done by useBreakpoints() — exposed on its own for headless art-direction setups.

Sizes attribute helper ​

buildSizes() builds a sizes attribute string from a breakpoint-keyed object — works with the plugin's named breakpoints.

ts
import { buildSizes } from '@macrulez/vue-image-kit'

const breakpoints = { sm: '(max-width: 640px)', md: '(max-width: 1024px)' }

buildSizes({ sm: '100vw', md: '50vw', default: '33vw' }, breakpoints)
// → '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'

generatePreloadLink() generates a <link rel="preload"> HTML string for critical above-the-fold images. Use in Nuxt's useHead or inject into SSR <head> to improve LCP.

ts
import { generatePreloadLink, generateSrcset } from '@macrulez/vue-image-kit'

const srcset = generateSrcset('/hero.jpg', [400, 800, 1200])

const link = generatePreloadLink('/hero.jpg', {
  srcset,
  sizes: '100vw',
})
// → '<link rel="preload" as="image" href="/hero.jpg" imagesrcset="..." imagesizes="100vw">'

In Nuxt:

vue
<script setup lang="ts">
import { generatePreloadLink } from '@macrulez/vue-image-kit'

useHead({
  link: [{ innerHTML: generatePreloadLink('/hero.jpg', { sizes: '100vw' }) }],
})
</script>

Layout without a wrapper ​

<VImage> doesn't render inside a wrapper element. At any given moment it's exactly one box: a placeholder before loading starts, the real <img> once it does — the second replaces the first outright, it doesn't wrap it. When the image needs a <picture> (a src object, sources), the <picture> is only the container of the <source> elements: it is rendered with display: contents, so it makes no box of its own and the <img> inside it lays out as if it stood alone. Every layout style (below) applies directly to that one box.

The practical upshot: class, style, data-*, event listeners and any other attribute passed to <VImage> always land on the element that shows the image — the placeholder, the error box or the <img> (also inside a <picture>) — never on an intermediate wrapper. A rule such as .card-image { width: 100%; height: auto; border-radius: 10px } styles the picture itself, with or without sources, and needs no selector reaching into the component. The same holds for the server-rendered <img>, and the class is copied onto the image inside the <noscript> fallback of an ssrPlaceholder image.

Layout presets ​

The layout prop switches how that one rendered element is sized. Leaving it unset behaves identically to 'responsive' below in every respect, including its auto-generated sizes — there's no need to set layout="responsive" explicitly just to get that heuristic, leaving layout unset already gets it.

fixed — an exact width×height box, no responsive scaling (like a plain <img width height>):

vue
<VImage src="/icon.jpg" alt="Icon" :width="64" :height="64" layout="fixed" />

responsive — fills the container width, aspect-ratio preserved from width/height, and auto-generates a sizes value from width when sizes isn't given explicitly ((min-width: {width}px) {width}px, 100vw — "as wide as its intrinsic size, otherwise the full viewport width"). This is also exactly what you get by leaving layout unset:

vue
<VImage
  src="/photo.jpg"
  alt="Photo"
  :width="800"
  :height="600"
  :widths="[400, 800, 1200]"
  layout="responsive"
/>

Without width/height, there's no intrinsic ratio to preserve, so <VImage> doesn't force any sizing at all — the element renders at its own natural size, fully governed by whatever CSS you give it (e.g. a max-height on your own class).

fill — absolutely fills a positioned parent (position: absolute; inset: 0, applied directly to the element itself); the parent needs position: relative (or similar) itself. width/height become optional — common for hero banners or cards where the container defines the box:

vue
<div style="position: relative; aspect-ratio: 16 / 9;">
  <VImage src="/hero.jpg" alt="Hero" layout="fill" fit="cover" priority />
</div>