UIPackage

Lazy Image

Vue data-display
Edit on GitHub

Lazy-loaded image with aspect-ratio reservation, skeleton placeholder, fade-in transition, and error fallback. Composes loading="lazy" with IntersectionObserver for off-viewport hold and pairs with the skeleton primitive for the placeholder state.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/lazy-image.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/lazy-image

Examples

Props

Name Type / Values Default Required
src string required
srcset string optional
sizes string optional
alt string required
aspectRatio string | number optional
width string | number optional
height string | number optional
placeholder Placeholder 'skeleton' optional
cover boolean true optional
eager boolean false optional
fallback string optional
transition boolean true optional
class HTMLAttributes['class'] optional
imgClass HTMLAttributes['class'] optional

Dependencies

Files (2)

  • app/components/ui/lazy-image/Img.vue 4 kB
    <script setup lang="ts">
    import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    import { Skeleton } from '@/components/ui/skeleton'
    
    type Placeholder = 'skeleton' | 'none'
    
    const props = withDefaults(
      defineProps<{
        src: string
        srcset?: string
        sizes?: string
        alt: string
        aspectRatio?: string | number
        width?: string | number
        height?: string | number
        placeholder?: Placeholder
        cover?: boolean
        eager?: boolean
        fallback?: string
        transition?: boolean
        class?: HTMLAttributes['class']
        imgClass?: HTMLAttributes['class']
      }>(),
      {
        placeholder: 'skeleton',
        cover: true,
        eager: false,
        transition: true,
      },
    )
    
    const emits = defineEmits<{
      (e: 'load', event: Event): void
      (e: 'error', event: Event): void
    }>()
    
    const root = ref<HTMLElement | null>(null)
    const state = ref<'idle' | 'loading' | 'loaded' | 'error'>('idle')
    const visible = ref(props.eager)
    
    const aspectStyle = computed((): Record<string, string> => {
      const r = props.aspectRatio
      if (r === undefined) return {}
      if (typeof r === 'number') return { aspectRatio: String(r) }
      return { aspectRatio: r }
    })
    
    const sizeStyle = computed((): Record<string, string> => {
      const out: Record<string, string> = {}
      if (props.width !== undefined) out.width = typeof props.width === 'number' ? `${props.width}px` : props.width
      if (props.height !== undefined) out.height = typeof props.height === 'number' ? `${props.height}px` : props.height
      return out
    })
    
    const containerStyle = computed(() => ({ ...aspectStyle.value, ...sizeStyle.value }))
    
    let observer: IntersectionObserver | null = null
    
    function setupObserver() {
      if (props.eager || typeof IntersectionObserver === 'undefined' || !root.value) {
        visible.value = true
        return
      }
      observer = new IntersectionObserver(
        (entries) => {
          for (const entry of entries) {
            if (entry.isIntersecting) {
              visible.value = true
              observer?.disconnect()
              observer = null
              return
            }
          }
        },
        { rootMargin: '200px' },
      )
      observer.observe(root.value)
    }
    
    onMounted(() => {
      setupObserver()
    })
    
    onBeforeUnmount(() => {
      observer?.disconnect()
      observer = null
    })
    
    watch(
      () => props.src,
      () => {
        state.value = 'idle'
        if (!props.eager) {
          visible.value = false
          setupObserver()
        }
      },
    )
    
    watch(visible, (v) => {
      if (v && state.value === 'idle') state.value = 'loading'
    })
    
    function onLoad(e: Event) {
      state.value = 'loaded'
      emits('load', e)
    }
    
    function onError(e: Event) {
      state.value = 'error'
      emits('error', e)
    }
    </script>
    
    <template>
      <div
        ref="root"
        data-uipkge
        data-slot="lazy-image"
        :class="cn('bg-muted relative overflow-hidden', props.class)"
        :style="containerStyle"
      >
        <Skeleton
          v-if="placeholder === 'skeleton' && state !== 'loaded' && state !== 'error'"
          class="absolute inset-0 size-full rounded-none"
        />
    
        <img
          v-if="visible && state !== 'error'"
          :src="src"
          :srcset="srcset"
          :sizes="sizes"
          :alt="alt"
          :loading="eager ? 'eager' : 'lazy'"
          :decoding="eager ? 'sync' : 'async'"
          :class="
            cn(
              'block size-full',
              cover ? 'object-cover' : 'object-contain',
              transition && 'transition-opacity duration-300',
              state === 'loaded' ? 'opacity-100' : 'opacity-0',
              imgClass,
            )
          "
          @load="onLoad"
          @error="onError"
        />
    
        <template v-if="state === 'error'">
          <slot name="fallback">
            <img
              v-if="fallback"
              :src="fallback"
              :alt="alt"
              :class="cn('block size-full', cover ? 'object-cover' : 'object-contain', imgClass)"
            />
            <div
              v-else
              class="text-muted-foreground absolute inset-0 flex items-center justify-center text-xs"
              aria-label="Image failed to load"
            >
              <span>Image unavailable</span>
            </div>
          </slot>
        </template>
      </div>
    </template>
  • app/components/ui/lazy-image/index.ts 0 kB
    export { default as Img } from './Img.vue'

Raw manifest: https://uipkge.dev/r/vue/lazy-image.json