UIPackage

Lazy Image

React 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 Vue ->

Installation

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

Or with the named registry: npx shadcn@latest add @uipkge-react/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 optional
cover boolean optional
eager boolean optional
fallback string optional
transition boolean optional
className string optional
imgClassName string optional
fallbackContent

`#fallback` named slot).

React.ReactNode optional
onLoad (e: React.SyntheticEvent<HTMLImageElement, Event>) => void optional
onError (e: React.SyntheticEvent<HTMLImageElement, Event>) => void optional

Dependencies

Files (2)

  • components/ui/lazy-image/Img.tsx 4.4 kB
    'use client'
    
    import * as React from 'react'
    import { cn } from '@/lib/utils'
    import { Skeleton } from '@/components/ui/skeleton'
    
    type Placeholder = 'skeleton' | 'none'
    
    export interface ImgProps {
      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
      className?: string
      imgClassName?: string
      /** Override the default error fallback (React equivalent of the Vue
       *  `#fallback` named slot). */
      fallbackContent?: React.ReactNode
      onLoad?: (e: React.SyntheticEvent<HTMLImageElement, Event>) => void
      onError?: (e: React.SyntheticEvent<HTMLImageElement, Event>) => void
    }
    
    function Img({
      src,
      srcSet,
      sizes,
      alt,
      aspectRatio,
      width,
      height,
      placeholder = 'skeleton',
      cover = true,
      eager = false,
      fallback,
      transition = true,
      className,
      imgClassName,
      fallbackContent,
      onLoad,
      onError,
    }: ImgProps) {
      const rootRef = React.useRef<HTMLDivElement | null>(null)
      const [state, setState] = React.useState<'idle' | 'loading' | 'loaded' | 'error'>('idle')
      const [visible, setVisible] = React.useState(eager)
    
      const aspectStyle: React.CSSProperties = React.useMemo(() => {
        if (aspectRatio === undefined) return {}
        if (typeof aspectRatio === 'number') return { aspectRatio: String(aspectRatio) }
        return { aspectRatio }
      }, [aspectRatio])
    
      const sizeStyle: React.CSSProperties = React.useMemo(() => {
        const out: React.CSSProperties = {}
        if (width !== undefined) out.width = typeof width === 'number' ? `${width}px` : width
        if (height !== undefined) out.height = typeof height === 'number' ? `${height}px` : height
        return out
      }, [width, height])
    
      const containerStyle = { ...aspectStyle, ...sizeStyle }
    
      // Reset + (re)observe when the source changes.
      React.useEffect(() => {
        setState('idle')
        if (eager) {
          setVisible(true)
          return
        }
        setVisible(false)
    
        if (typeof IntersectionObserver === 'undefined' || !rootRef.current) {
          setVisible(true)
          return
        }
    
        const observer = new IntersectionObserver(
          (entries) => {
            for (const entry of entries) {
              if (entry.isIntersecting) {
                setVisible(true)
                observer.disconnect()
                return
              }
            }
          },
          { rootMargin: '200px' },
        )
        observer.observe(rootRef.current)
        return () => observer.disconnect()
      }, [src, eager])
    
      // Transition idle -> loading once the element becomes visible.
      React.useEffect(() => {
        if (visible) setState((s) => (s === 'idle' ? 'loading' : s))
      }, [visible])
    
      function handleLoad(e: React.SyntheticEvent<HTMLImageElement, Event>) {
        setState('loaded')
        onLoad?.(e)
      }
    
      function handleError(e: React.SyntheticEvent<HTMLImageElement, Event>) {
        setState('error')
        onError?.(e)
      }
    
      return (
        <div
          ref={rootRef}
          data-uipkge=""
          data-slot="lazy-image"
          className={cn('bg-muted relative overflow-hidden', className)}
          style={containerStyle}
        >
          {placeholder === 'skeleton' && state !== 'loaded' && state !== 'error' && (
            <Skeleton className="absolute inset-0 size-full rounded-none" />
          )}
    
          {visible && state !== 'error' && (
            <img
              src={src}
              srcSet={srcSet}
              sizes={sizes}
              alt={alt}
              loading={eager ? 'eager' : 'lazy'}
              decoding={eager ? 'sync' : 'async'}
              className={cn(
                'block size-full',
                cover ? 'object-cover' : 'object-contain',
                transition && 'transition-opacity duration-300',
                state === 'loaded' ? 'opacity-100' : 'opacity-0',
                imgClassName,
              )}
              onLoad={handleLoad}
              onError={handleError}
            />
          )}
    
          {state === 'error' &&
            (fallbackContent !== undefined ? (
              fallbackContent
            ) : fallback ? (
              <img
                src={fallback}
                alt={alt}
                className={cn('block size-full', cover ? 'object-cover' : 'object-contain', imgClassName)}
              />
            ) : (
              <div
                className="text-muted-foreground absolute inset-0 flex items-center justify-center text-xs"
                aria-label="Image failed to load"
              >
                <span>Image unavailable</span>
              </div>
            ))}
        </div>
      )
    }
    
    export { Img }
  • components/ui/lazy-image/index.ts 0 kB
    export { Img, type ImgProps } from './Img'

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