SwImage
Running pnpm optimize-images (or triggered automatically before pnpm build) recursively processes all jpg/jpeg/png/gif/webp files under public/images/, generating avif and webp versions at four widths (640w, 1024w, 1280w, 1920w) in public/images/optimized/.
The component automatically builds srcset from the src path. The browser selects the best available format in priority order: avif → webp → original.
When to use SwImage
SwImage is not the catch-all for every image — first decide which component to use, then read the usage below:
| Asset type | Use |
|---|---|
| Bitmap content image (jpg/png/webp: cover / banner / product / avatar / hero) | SwImage |
Business SVG icon (assets/icons/, reused across pages) | AppIcon |
| UI functional icon (nav / button / arrow / status) | Icon library (@phosphor-icons/vue etc.) / SwIcon |
| Single SVG logo / illustration (needs placeholder or load-fail fallback) | SwImage fit="contain" |
| Pure decorative small image / inline dataURI / sprite / 1px divider | Native <img> (with alt) |
See the framework dev standards "Image asset selection" for full rationale and edge cases.
Local vs External Images
- Local images (starting with
/images/): rendered as a<picture>element with pregenerated avif/webp srcsets. - SVG images: output directly as
<img>— no optimization pipeline. - External URLs (
http/https): output directly as<img>, skipping build-time pre-generation; with the defaultnoopprovider no srcset is generated, but once aswiftConfig.imageprovider is configured the CDN generates srcset per WIDTHS (seeproviderbelow).
<!-- Local image: automatically uses pregenerated avif/webp srcset -->
<SwImage src="/images/cover.jpg" alt="Cover" ratio="16/9" />
<!-- SVG: direct output, no optimization -->
<SwImage src="/images/logo.svg" alt="Logo" fit="contain" />
<!-- External image: no srcset -->
<SwImage src="https://picsum.photos/400/300" alt="Example" ratio="4/3" />
<!-- Per-image provider override: force 'noop' for signed URLs (bypass CDN transforms) -->
<SwImage :src="signedUrl" alt="Example" ratio="4/3" provider="noop" />
Basic Example
Basic Usage
Compares container height with and without the ratio='16/9' prop.
Aspect Ratio
Setting the ratio prop makes the container reserve height proportionally before the image loads, preventing Cumulative Layout Shift (CLS). Recommended for all content images.
When ratio is set, the component also emits width / height attributes on the <img> proportionally (e.g. ratio="16/9" → width="16" height="9"), satisfying web-vitals / Lighthouse image-dimension checks. These attributes express the ratio only (not display pixels) — the actual size is still controlled by CSS (width:100%), so the image stays fully responsive; the attrs merely let the browser compute aspect-ratio to reserve space.
Aspect Ratio Comparison
Visual comparison of 1/1 (avatar), 4/3 (photo), and 16/9 (video/banner).
fit comparison (cover/contain/fill)
Failure fallback (placeholder vs disabled)
Styling the placeholder layer (placeholderClass)
@load / @error events
Sizing Mental Model (three scenarios)
SwImage's sizing philosophy is "let the outside decide the size": with ratio it fills the parent's width by default and derives the height from the ratio; with fill it fills the parent completely. When the layout misbehaves, find your case below rather than patching it with a :deep override (this table was paid for by several applier incidents: anibay#8 / bay-admin#40 / cinebay#7 / bay-admin#66):
| Scenario | Correct approach | Why |
|---|---|---|
| Fluid content image (card grids / galleries, scales with the column) | Pass ratio | The container emits an inline aspect-ratio; width defaults to 100%, filling the cell and reserving height up front (prevents CLS) |
| Fill the parent (fixed-height hero / curated strip header / fixed-height ad slot, where the parent positions or sizes itself) | Pass fill | Both the container and the <img> go 100%×100% and no aspect-ratio is emitted — sizing is left entirely to the parent; fit still applies |
| Fixed-width thumbnail (avatar / mini cover / table thumbnail, where the image must obey one fixed size) | Pass ratio and put width straight on the component | The 100% default width in ratio mode comes from a zero-specificity stylesheet rule :where(.sw-image.is-ratio), not an inline style — any class you put on the component (Tailwind w-12 included) wins outright; no wrapper, no !important |
<!-- ✅ A fixed-width class straight on SwImage just works; inside a flex row add flex-shrink: 0 -->
<SwImage class="avatar" src="/a.jpg" alt="" ratio="1/1" />
<style>.avatar { width: 3rem; flex-shrink: 0; }</style>
One anti-pattern, with a real-world failure behind it:
<!-- ❌ Reaching in with :deep to absolutely position the container — fragile, and `fill` already does this -->
<style>.hero :deep(.sw-image) { position: absolute; inset: 0; }</style>
<!-- ✅ The parent handles positioning/height, SwImage takes `fill` -->
<div class="hero"><SwImage src="/h.jpg" alt="" fit="cover" fill /></div>
<style>.hero { position: relative; height: 24rem; }</style>
Common Scenarios
<!-- Hero image (LCP trio): eager loading + preload injects <link rel="preload">
+ fetchpriority maxes network priority (encrypted .bnc images don't support preload, see Caveats) -->
<SwImage src="/images/hero.jpg" alt="Hero" ratio="21/9" loading="eager" preload fetchpriority="high" :placeholder="false" />
<!-- Avatar: contain to show full image, 1:1 ratio -->
<SwImage src="/images/avatar.png" alt="Avatar" ratio="1/1" fit="contain" />
<!-- Blurred cover backdrop: blur applies Gaussian blur (common detail-page background pattern) -->
<SwImage src="/images/cover.jpg" alt="" :blur="12" ratio="16/9" />
<!-- With placeholder: show brand logo while loading -->
<SwImage src="/images/product.jpg" alt="Product" ratio="1/1" placeholder="/images/logo.svg" />
<!-- Listen to load events -->
<SwImage src="/images/cover.jpg" alt="Cover" @load="onLoad" @error="onError" />
Whole-card Click + Hover Zoom
The wrapper exposes a click event (covers clicks on the overlay area too).
Add the hover-zoom class to enable hover scaling.
The combination is the standard pattern for cover cards:
<SwImage
src="/images/cover.jpg"
alt="Movie cover"
ratio="3/4"
class="hover-zoom"
@click="goDetail"
>
<div class="overlay">
<span class="play-icon">▶</span>
<span class="title">{{ item.title }}</span>
</div>
</SwImage>
hover-zoom defaults to 1.1×. Override the strength via CSS variable:
:root {
--sw-image-hover-zoom-scale: 1.05; /* less aggressive */
--sw-image-hover-zoom-duration: 0.6s; /* slower */
}
Theme-aware Placeholder
placeholder accepts { dark, light } objects.
The component swaps placeholder images via pure CSS
(<html data-theme> + prefers-color-scheme) — zero runtime overhead:
::alert{type="info"}
**Under `prefers-reduced-motion: reduce` the zoom is disabled automatically** — the component ships that fallback, you don't have to add it. Scaling counts as a non-essential animation and can trigger vertigo for users with vestibular disorders (standards UX-004 / WCAG 2.3.3).
The zoom also applies **only to the real image**: placeholders never scale (magnifying a placeholder logo while the image is still loading is pointless), and images inside your slot are left alone — that animation is yours to decide.
::
vue
<!-- placeholder-class: attach a custom class to the placeholder div (e.g. skeleton pulse animation) -->
<SwImage
src="/images/cover.jpg"
alt="..."
placeholder-class="skeleton-pulse"
:placeholder="{
dark: '/images/placeholder-dark.png',
light: '/images/placeholder-light.png',
}"
/>
Custom Placeholder Content
The #placeholder slot takes precedence over the placeholder prop and can hold arbitrary content (skeleton animations, branded loaders, etc.):
<SwImage src="/images/cover.jpg" alt="...">
<template #placeholder>
<div class="my-skeleton" />
</template>
</SwImage>
Exposed Methods (defineExpose)
| Member | Signature | Description |
|---|---|---|
getElement | () => HTMLImageElement | null | Returns the real <img> currently rendered (works across all three branches: encrypted / <picture> / plain). Returns null during SSR and before an encrypted image finishes decoding — reading it after @load is the safe moment |
<script setup lang="ts">
const imgRef = ref()
function measure() {
const el = imgRef.value?.getElement()
if (el) console.log(el.naturalWidth, el.naturalHeight) // intrinsic size of the real image
}
</script>
<template>
<SwImage ref="imgRef" src="/images/cover.jpg" alt="Cover" @load="measure" />
</template>
🔴 Never guess the internal DOM with querySelector. The component renders several <img> elements at once (up to 3 placeholders, and they come before the real image in DOM order), so querySelector('img') returns a placeholder — silently, giving you the placeholder SVG's dimensions instead. The internal classes (.img, .placeholder-img) are implementation details, not a contract, and may change at any time; always go through getElement() for measuring, decode(), or attaching observers.
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
src | string | ✓ | — | Image URL. Supports .bnc encrypted paths. An empty / undefined value at runtime renders the error placeholder (.placeholder.is-error) instead of throwing and breaking SSR |
alt | string | ✓ | — | Accessibility description |
imgKey | string | '' | Override key for .bnc decryption. Defaults to useSystemStore().info.img_key | |
ratio | string | number | — | Aspect ratio: "16/9", "16:9", or numeric 420 / 300. Width defaults to filling the parent (zero-specificity stylesheet rule, not inline), so a width written straight on the component takes effect | |
sizes | string | auto | Custom srcset sizes media query | |
fit | 'cover' | 'contain' | 'fill' | 'none' | 'scale-down' | 'cover' | Image fill mode (CSS object-fit) | |
loading | 'lazy' | 'eager' | 'lazy' | Loading strategy. Use 'eager' for above-the-fold images | |
fetchpriority | 'auto' | 'high' | 'low' | 'auto' | Resource priority. Use 'high' for hero images | |
preload | boolean | false | LCP optimization: injects <link rel="preload" as="image"> into <head> so the browser starts fetching during HTML parse. Not supported for the encrypted (.bnc) branch since the actual rendered URL is a decrypted blob | |
provider | string | undefined | Per-image override for swiftConfig.image routing. Pass 'noop' to force "no processing" (original URL); pass a specific provider name (e.g. 'cf-image') to force that handler. Only affects the external-image branch; encrypted / local images bypass naturally | |
fill | boolean | false | Fill-parent mode: container and <img> get 100%×100% and no inline aspect-ratio (sizing is fully delegated to the parent — fixed-height hero, fixed-height ad slots, …); fit still applies. Mutually exclusive with ratio (fill wins + dev warning) | |
decoding | 'async' | 'sync' | 'auto' | 'async' | Image decoding mode | |
placeholder | string | { dark, light } | false | false | Fallback image. String = single; object = theme-switched; false disables | |
placeholderClass | string | '' | Class hook applied to the placeholder div (SwImage-managed only) | |
title | string | '' | Native title attribute (hover tooltip / SEO) | |
radius | number | string | 0 | Border radius. number auto-appends px | |
blur | number | 0 | Gaussian blur radius in px | |
backgroundColor | string | 'transparent' | Container background color |
Events
| Event | Payload | Description |
|---|---|---|
click | MouseEvent | Wrapper click — fires for clicks on the overlay area too |
load | Event | <img> finished loading |
error | Event | <img> failed to load |
Slots
| Name | Description |
|---|---|
default | Overlay content (video player, text mask, etc.). Z-index above image and placeholder |
placeholder | Custom placeholder content. Overrides placeholder prop's default rendering |
CSS Variables
| Variable | Default | Description |
|---|---|---|
--sw-image-hover-zoom-scale | 1.1 | Hover scale factor when hover-zoom class is applied |
--sw-image-hover-zoom-duration | 0.25s | Transition duration of the hover zoom (pairs with scale — tune this for a slower drift or a snappier pop) |
--sw-image-placeholder-bg | transparent | Placeholder background (loading state). Override this variable for a custom placeholder background |
--sw-image-placeholder-error-bg | color-mix(in srgb, var(--destructive) 20%, transparent) | Placeholder background on load failure (defaults to a --destructive red tint). The placeholder gets .is-error on error and switches to this color |
Load-failure placeholder
When an image fails to load, the placeholder stays visible and gains .is-error, switching its background to --sw-image-placeholder-error-bg (red-tinted by default) to distinguish it from the loading state:
<!-- Custom loading / error placeholder backgrounds -->
<style>
.sw-image .placeholder { --sw-image-placeholder-bg: #16110f; }
.sw-image .placeholder.is-error { --sw-image-placeholder-error-bg: #3c0001; }
</style>
For SSR-rendered images that fail before hydration, the
@errorevent can fire before the listener is attached and be missed; the component re-checks inonMounted(img.complete && naturalWidth === 0→ error) so the error placeholder shows reliably.
Notes
- When
srcends with.bnc, the encrypted branch handles AES decryption + IndexedDB caching internally (shared withSwAsyncTextviauseDecryptedAsset), client-only. - For LCP-critical hero images, set both
loading="eager"andfetchpriority="high". - The placeholder (
.placeholder) usesisolation: isolateto contain its stacking context, so it won't cover overlays the consumer places on top of the image (titles / badges, etc.).