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 typeUse
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 dividerNative <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 default noop provider no srcset is generated, but once a swiftConfig.image provider is configured the CDN generates srcset per WIDTHS (see provider below).
<!-- 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):

ScenarioCorrect approachWhy
Fluid content image (card grids / galleries, scales with the column)Pass ratioThe 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 fillBoth 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 componentThe 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)

MemberSignatureDescription
getElement() => HTMLImageElement | nullReturns 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

NameTypeRequiredDefaultDescription
srcstring✓—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
altstring✓—Accessibility description
imgKeystring''Override key for .bnc decryption. Defaults to useSystemStore().info.img_key
ratiostring | 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
sizesstringautoCustom 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
preloadbooleanfalseLCP 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
providerstringundefinedPer-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
fillbooleanfalseFill-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
placeholderstring | { dark, light } | falsefalseFallback image. String = single; object = theme-switched; false disables
placeholderClassstring''Class hook applied to the placeholder div (SwImage-managed only)
titlestring''Native title attribute (hover tooltip / SEO)
radiusnumber | string0Border radius. number auto-appends px
blurnumber0Gaussian blur radius in px
backgroundColorstring'transparent'Container background color

Events

EventPayloadDescription
clickMouseEventWrapper click — fires for clicks on the overlay area too
loadEvent<img> finished loading
errorEvent<img> failed to load

Slots

NameDescription
defaultOverlay content (video player, text mask, etc.). Z-index above image and placeholder
placeholderCustom placeholder content. Overrides placeholder prop's default rendering

CSS Variables

VariableDefaultDescription
--sw-image-hover-zoom-scale1.1Hover scale factor when hover-zoom class is applied
--sw-image-hover-zoom-duration0.25sTransition duration of the hover zoom (pairs with scale — tune this for a slower drift or a snappier pop)
--sw-image-placeholder-bgtransparentPlaceholder background (loading state). Override this variable for a custom placeholder background
--sw-image-placeholder-error-bgcolor-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 @error event can fire before the listener is attached and be missed; the component re-checks in onMounted (img.complete && naturalWidth === 0 → error) so the error placeholder shows reliably.

Notes

  • When src ends with .bnc, the encrypted branch handles AES decryption + IndexedDB caching internally (shared with SwAsyncText via useDecryptedAsset), client-only.
  • For LCP-critical hero images, set both loading="eager" and fetchpriority="high".
  • The placeholder (.placeholder) uses isolation: isolate to contain its stacking context, so it won't cover overlays the consumer places on top of the image (titles / badges, etc.).