Skip to content

@carrot/design-vue-images

Vue 3 image components for the Carrot Design System. LQIP blur-up crossfade, lazy loading, global load queue with concurrency control, and responsive <picture> support.

Installation

ts
import { CarrotImage, CarrotBgImage, CarrotPicture, CarrotSvg } from "@carrot/design-vue-images";

Requires the types from @carrot/design-components-images. No CSS import needed (styles are inline/scoped).

Components

CarrotImage

Standard image with LQIP blur-up crossfade, lazy loading, and auto aspect-ratio.

Usage

vue
<!-- Basic -->
<CarrotImage src="/photos/hero.jpg" alt="Hero shot" />

<!-- LQIP blur-up -->
<CarrotImage src="/photos/hero-full.jpg" thumb-src="/photos/hero-thumb.jpg" alt="Hero shot" />

<!-- Eager loading (above the fold) -->
<CarrotImage src="/hero.jpg" alt="Hero" loading="eager" fetchpriority="high" />

<!-- Fixed dimensions -->
<CarrotImage src="/avatar.jpg" alt="User" :width="200" :height="200" />

<!-- Fit and position -->
<CarrotImage src="/photo.jpg" alt="Photo" fit="contain" position="top-center" />

<!-- Border radius (token shorthand) -->
<CarrotImage src="/photo.jpg" alt="Photo" radius="lg" />
<CarrotImage src="/photo.jpg" alt="Photo" radius="full" />

<!-- No crossfade -->
<CarrotImage src="/photo.jpg" alt="Photo" :crossfade="false" />

<!-- Custom duration -->
<CarrotImage src="/photo.jpg" alt="Photo" :duration="500" />

<!-- Srcset for responsive -->
<CarrotImage
  src="/photo-800.jpg"
  srcset="/photo-400.jpg 400w, /photo-800.jpg 800w, /photo-1200.jpg 1200w"
  sizes="(max-width: 600px) 400px, 800px"
  alt="Responsive"
/>

<!-- Polymorphic (render as different element) -->
<CarrotImage :as="NuxtImg" src="/photo.jpg" alt="Via NuxtImg" />

<!-- Placeholder + error slots -->
<CarrotImage src="/photo.jpg" alt="Photo">
  <template #placeholder><MySkeleton /></template>
  <template #error><MyErrorState /></template>
</CarrotImage>

Props

PropTypeDefaultDescription
srcstringImage URL
thumbSrcstringLow-quality placeholder URL (blurred while full loads)
altstring""Alt text
srcsetstringResponsive srcset
sizesstringResponsive sizes
widthstring | numberWidth
heightstring | numberHeight
fitImageFit"cover"object-fit
positionImagePositionobject-position
loadingImageLoadingStrategy"lazy"lazy (IntersectionObserver) or eager
fetchpriorityFetchPriorityBrowser fetch priority
decodingImageDecoding"async"Decoding hint
crossfadebooleantrueEnable crossfade transition
durationnumber300Crossfade duration (ms)
radiusstringBorder radius (token name or CSS value)
aspectstringautoAspect ratio (auto-computed from intrinsic dimensions)
draggablebooleanfalseHTML draggable
asstring | Component"img"Polymorphic render element

Events

EventPayloadDescription
load(src: string, dimensions: ImageDimensions)Full image loaded
error(src: string | undefined)Image failed
statusChange(status: ImageStatus, src: string | null)Status transition

Slots

SlotDescription
placeholderCustom loading placeholder
errorCustom error state

How LQIP Works (3-slot crossfade)

The component uses a 3-slot system (back/front/pending) to handle rapid source changes gracefully:

  • Back slot (backSrc): Currently visible, fully opaque
  • Front slot (frontSrc): Fading in over the back slot
  • Pending slot (pendingSrc): Queued source, not yet rendered — freely overwritten if the source changes again before the front finishes fading
  1. thumbSrc loads immediately (tiny, fast) into the back slot
  2. Displays blurred (blur(20px) + scale(1.1) to hide edges)
  3. src loads in background via the image queue
  4. Once loaded, the full image enters the front slot and crossfades over the back
  5. When the transition ends, front promotes to back and any pending source starts fading
  6. Auto aspect-ratio computed from intrinsic dimensions to prevent CLS

CarrotBgImage

Background image container with LQIP, overlay support, and slot for content.

Usage

vue
<!-- Basic -->
<CarrotBgImage src="/hero.jpg" aria-label="Hero background">
  <h1>Welcome</h1>
</CarrotBgImage>

<!-- With overlay -->
<CarrotBgImage src="/hero.jpg" :overlay="true">
  <h1 class="text-white">Darkened overlay</h1>
</CarrotBgImage>

<!-- Custom overlay gradient -->
<CarrotBgImage src="/hero.jpg" overlay="linear-gradient(to bottom, transparent, rgba(0,0,0,0.8))">
  <h1>Gradient overlay</h1>
</CarrotBgImage>

<!-- Fixed (parallax-style) -->
<CarrotBgImage src="/hero.jpg" fixed aria-label="Parallax background" />

<!-- LQIP -->
<CarrotBgImage src="/hero-full.jpg" thumb-src="/hero-thumb.jpg" aria-label="Hero" />

Props

PropTypeDefaultDescription
srcstringImage URL
thumbSrcstringLQIP placeholder URL
fitBgFit"cover"background-size
positionBgPosition"center"background-position
loadingImageLoadingStrategy"lazy"Loading strategy
fixedbooleanfalsebackground-attachment: fixed
overlaystring | booleanOverlay gradient (true = 40% black)
width / heightstring | numberContainer dimensions
radiusstringBorder radius
aspectstringAspect ratio
ariaLabelstringAccessible label (sets role="img")

CarrotPicture

Responsive <picture> element with multiple sources, LQIP, and crossfade. Smart rendering: thumb renders as plain <img> (blurred) to avoid <source> elements triggering premature full-res loads.

Usage

vue
<CarrotPicture
  src="/photo.jpg"
  thumb-src="/photo-thumb.jpg"
  alt="Responsive photo"
  :sources="[
    { media: '(min-width: 1200px)', srcset: '/photo-xl.webp', type: 'image/webp' },
    { media: '(min-width: 800px)', srcset: '/photo-lg.webp', type: 'image/webp' },
    { srcset: '/photo-sm.webp', type: 'image/webp' },
  ]"
/>

Props

Inherits all CarrotImage props except as, srcset, sizes, plus:

PropTypeDefaultDescription
sourcesPictureSource[][]Responsive sources (media, srcset, type, sizes)


CarrotSvg

Inline SVG renderer. Fetches an SVG file, inlines it into the DOM, and optionally replaces named colors. Fetched SVGs are cached in memory to avoid repeat requests.

Usage

vue
<!-- Basic -->
<CarrotSvg src="/icons/logo.svg" alt="Logo" />

<!-- With sizing -->
<CarrotSvg src="/icons/logo.svg" alt="Logo" :width="48" :height="48" />

<!-- Color replacement -->
<CarrotSvg
  src="/icons/icon.svg"
  alt="Icon"
  :colors="{ '#000': 'var(--text-primary)', '#ff0000': 'var(--color-danger)' }"
/>

<!-- Border radius -->
<CarrotSvg src="/illustrations/hero.svg" alt="Hero" radius="lg" />

Props

PropTypeDefaultDescription
srcstringURL to the SVG file
altstring""Accessible label (sets role="img" + aria-label)
widthstring | numberWidth (CSS value or number in px)
heightstring | numberHeight (CSS value or number in px)
radiusstringBorder radius (token name or CSS value)
colorsRecord<string, string>Color map — replace named colors in the SVG source

Composables

useSvgFetch(src)

Fetches an SVG file as text, with in-memory caching and in-flight request deduplication. Returns reactive svgContent and error refs.

ts
import { useSvgFetch } from "@carrot/design-vue-images";

const src = ref("/icons/logo.svg");
const { svgContent, error } = useSvgFetch(src);
// svgContent: Ref<string | null> — the raw SVG markup
// error: Ref<boolean> — true if the fetch failed

useImageLoad(src)

Reactive image loader with generation tracking (prevents stale loads).

ts
import { useImageLoad } from "@carrot/design-vue-images";

const src = ref("/photo.jpg");
const { status, currentSrc, dimensions } = useImageLoad(src);
// status: "idle" | "loading" | "loaded" | "error"

useLazyLoad(target, options?)

IntersectionObserver-based visibility detection for lazy loading.

ts
import { useLazyLoad } from "@carrot/design-vue-images";

const el = ref<HTMLElement | null>(null);
const { isVisible } = useLazyLoad(el, {
  rootMargin: "200px",
  threshold: 0,
  enabled: () => props.loading === "lazy",
});

useImageQueue() / imageQueue

Global singleton queue with configurable concurrency (default: 3 concurrent loads).

ts
import { useImageQueue } from "@carrot/design-vue-images";

const { queue, clear, setConcurrency } = useImageQueue();
setConcurrency(5);
clear(); // cancel pending loads (e.g. on route change)

useRadius(radius)

Maps radius token shorthand to CSS values.

ts
import { useRadius } from "@carrot/design-vue-images";

const radius = useRadius(toRef(props, "radius"));
// "lg"      → "var(--radius-lg)"
// "full"    → "var(--radius-full)"
// "8px"     → "8px"

Image Statuses

StatusDescription
idleNo src set
loadingImage loading (or thumb loaded, full pending)
loadedFull image loaded
errorLoad failed

Dependencies

PackagePurpose
@carrot/design-components-imagesType definitions and constants

Build

bash
npm run build

Built with Vite + vite-plugin-dts. Outputs ES module with TypeScript declarations.


Usage Guide

Setup

ts
import { CarrotImage, CarrotBgImage, CarrotPicture, CarrotSvg } from "@carrot/design-vue-images";
// No CSS import needed — styles are inline/scoped

CarrotImage

Basic image with LQIP blur-up

vue
<script setup lang="ts">
import { CarrotImage } from "@carrot/design-vue-images";

defineProps<{ src: string; thumbSrc: string; alt: string }>();
</script>

<template>
  <!-- thumbSrc shows blurred immediately, then crossfades to full src when loaded -->
  <CarrotImage
    :src="src"
    :thumb-src="thumbSrc"
    :alt="alt"
    radius="md"
  />
</template>

Hero image — above the fold

vue
<template>
  <!-- Eager + high fetchpriority for LCP images -->
  <CarrotImage
    src="/hero-1200.jpg"
    srcset="/hero-600.jpg 600w, /hero-1200.jpg 1200w, /hero-2400.jpg 2400w"
    sizes="100vw"
    alt="Festival crowd"
    loading="eager"
    fetchpriority="high"
    fit="cover"
  />
</template>

Image with error fallback

vue
<template>
  <CarrotImage src="/artist-photo.jpg" alt="Artist photo">
    <template #placeholder>
      <div class="skeleton" style="width: 100%; height: 200px" />
    </template>
    <template #error>
      <div class="placeholder-image">No photo available</div>
    </template>
  </CarrotImage>
</template>

CarrotBgImage

Hero section with gradient overlay

vue
<script setup lang="ts">
import { CarrotBgImage } from "@carrot/design-vue-images";
</script>

<template>
  <CarrotBgImage
    src="/event-hero.jpg"
    thumb-src="/event-hero-thumb.jpg"
    overlay="linear-gradient(to bottom, transparent 40%, rgba(0,0,0,0.85))"
    aspect="21x9"
    aria-label="Event hero"
  >
    <div style="position: absolute; bottom: 32px; left: 32px; color: white">
      <h1>Warehouse Project</h1>
      <p>Manchester · Sat 12 April</p>
    </div>
  </CarrotBgImage>
</template>

CarrotPicture

Responsive art-directed picture

vue
<template>
  <CarrotPicture
    src="/photo-fallback.jpg"
    thumb-src="/photo-thumb.jpg"
    alt="Track artwork"
    :sources="[
      { media: '(min-width: 1024px)', srcset: '/photo-xl.webp', type: 'image/webp' },
      { media: '(min-width: 640px)', srcset: '/photo-lg.webp', type: 'image/webp' },
      { srcset: '/photo-sm.webp', type: 'image/webp' },
    ]"
    radius="lg"
  />
</template>

CarrotSvg

Inline SVG with color replacement

vue
<script setup lang="ts">
import { CarrotSvg } from "@carrot/design-vue-images";
</script>

<template>
  <!-- Basic inline SVG -->
  <CarrotSvg src="/icons/logo.svg" alt="Logo" :width="32" :height="32" />

  <!-- Replace colors for theming -->
  <CarrotSvg
    src="/icons/chart.svg"
    alt="Chart"
    :colors="{ '#000000': 'var(--text-primary)', '#0066ff': 'var(--color-accent)' }"
    :width="200"
  />
</template>

Composables

useImageQueue — control concurrency

vue
<script setup lang="ts">
import { useImageQueue } from "@carrot/design-vue-images";
import { onMounted, onBeforeUnmount } from "vue";

const { setConcurrency, clear } = useImageQueue();

onMounted(() => {
  // Allow more parallel loads on fast connections
  setConcurrency(6);
});

onBeforeUnmount(() => {
  // Cancel pending loads when leaving the page
  clear();
});
</script>

Common Patterns

Album art in a track row

vue
<script setup lang="ts">
import { CarrotImage } from "@carrot/design-vue-images";

defineProps<{ title: string; artwork: string; artworkThumb: string }>();
</script>

<template>
  <div style="display: flex; align-items: center; gap: 12px">
    <CarrotImage
      :src="artwork"
      :thumb-src="artworkThumb"
      :alt="`${title} artwork`"
      :width="48"
      :height="48"
      radius="sm"
    />
    <span>{{ title }}</span>
  </div>
</template>

Carrot