Image Loader

PreviousNext

An advanced image loading component with beautiful loading effect and error handling

Docs
rigiduicomponent

Preview

Loading preview…
r/new-york/image-loader/image-loader.tsx
"use client"

import * as React from "react"
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { cn } from "@/lib/utils"

const defaultColorCombinations = [
  { start: 'hsl(220, 15%, 8%)', middle: 'hsl(200, 12%, 18%)', end: 'hsl(240, 10%, 12%)' },
  { start: 'hsl(35, 25%, 15%)', middle: 'hsl(30, 20%, 25%)', end: 'hsl(25, 15%, 10%)' },
  { start: 'hsl(210, 20%, 12%)', middle: 'hsl(200, 15%, 22%)', end: 'hsl(215, 18%, 8%)' },
  { start: 'hsl(150, 30%, 8%)', middle: 'hsl(140, 25%, 15%)', end: 'hsl(160, 20%, 6%)' },
  { start: 'hsl(230, 35%, 6%)', middle: 'hsl(220, 30%, 12%)', end: 'hsl(240, 25%, 4%)' },
  { start: 'hsl(20, 40%, 12%)', middle: 'hsl(15, 35%, 18%)', end: 'hsl(10, 30%, 8%)' },
  { start: 'hsl(280, 25%, 10%)', middle: 'hsl(270, 20%, 16%)', end: 'hsl(290, 15%, 6%)' },
  { start: 'hsl(0, 0%, 8%)', middle: 'hsl(0, 0%, 18%)', end: 'hsl(0, 0%, 4%)' },
]

const hashString = (str: string): number => {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i);
    hash = ((hash << 5) - hash) + char;
    hash = hash & hash;
  }
  return Math.abs(hash);
}

const SVGPattern = React.memo(({
  colors,
  uniqueId,
  randomSeed
}: {
  colors: ColorCombination;
  uniqueId: string;
  randomSeed: number;
}) => {
  const gradientId = `ffflux-gradient-${uniqueId}`;
  const filterId = `ffflux-filter-${uniqueId}`;

  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      version="1.1"
      viewBox="0 0 100 100"
      width="100%"
      height="100%"
      preserveAspectRatio="xMidYMid slice"
      role="img"
      aria-label="Loading pattern"
    >
      <defs>
        <linearGradient
          gradientTransform="rotate(150, 0.5, 0.5)"
          x1="50%"
          y1="0%"
          x2="50%"
          y2="100%"
          id={gradientId}
        >
          <stop stopColor={colors.start} stopOpacity="1" offset="0%" />
          {colors.middle && (
            <stop stopColor={colors.middle} stopOpacity="1" offset="50%" />
          )}
          <stop stopColor={colors.end} stopOpacity="1" offset="100%" />
        </linearGradient>
        <filter
          id={filterId}
          x="-20%"
          y="-20%"
          width="140%"
          height="140%"
          filterUnits="objectBoundingBox"
          primitiveUnits="userSpaceOnUse"
          colorInterpolationFilters="sRGB"
        >
          <feTurbulence
            type="fractalNoise"
            baseFrequency="0.005 0.003"
            numOctaves={2}
            seed={randomSeed}
            stitchTiles="stitch"
            x="0%"
            y="0%"
            width="100%"
            height="100%"
            result="turbulence"
          />
          <feGaussianBlur
            stdDeviation="20 0"
            x="0%"
            y="0%"
            width="100%"
            height="100%"
            in="turbulence"
            edgeMode="duplicate"
            result="blur"
          />
          <feBlend
            mode="color-dodge"
            x="0%"
            y="0%"
            width="100%"
            height="100%"
            in="SourceGraphic"
            in2="blur"
            result="blend"
          />
        </filter>
      </defs>
      <rect
        width="100%"
        height="100%"
        fill={`url(#${gradientId})`}
        filter={`url(#${filterId})`}
      />
    </svg>
  );
})

SVGPattern.displayName = "SVGPattern";

interface ColorCombination {
  start: string;
  middle?: string;
  end: string;
}

interface ImageLoaderProps {
  src: string;
  alt: string;
  className?: string;
  blurIntensity?: string;
  width?: number | string;
  height?: number | string;
  fallbackComponent?: React.ReactNode;
  onLoad?: () => void;
  onError?: (error: Event) => void;
  loading?: "lazy" | "eager";
  objectFit?: "cover" | "contain" | "fill" | "none" | "scale-down";
  customColors?: ColorCombination[];
  seed?: number;
}

export function ImageLoader({
  src,
  alt,
  className,
  blurIntensity = "blur(50px)",
  width = 400,
  height = 300,
  fallbackComponent,
  onLoad,
  onError,
  loading = "lazy",
  objectFit = "cover",
  customColors,
  seed
}: ImageLoaderProps) {
  const [isLoaded, setIsLoaded] = useState(false)
  const [hasError, setHasError] = useState(false)
  const imgRef = useRef<HTMLImageElement>(null)

  if (!src) {
    console.warn('ImageLoader: src prop is required')
  }
  if (!alt) {
    console.warn('ImageLoader: alt prop is required for accessibility')
  }

  useEffect(() => {
    setIsLoaded(false)
    setHasError(false)
  }, [src])

  const patternData = useMemo(() => {
    const colorCombinations = customColors && customColors.length > 0 ? customColors : defaultColorCombinations

    const seedValue = seed !== undefined ? seed : hashString(src || 'default')
    const colorIndex = seedValue % colorCombinations.length
    const selectedColors = colorCombinations[colorIndex]

    const randomSeed = seedValue % 1000
    const uniqueId = `${hashString(src || 'default')}-${seedValue}`.slice(0, 9)

    return {
      colors: selectedColors,
      uniqueId,
      randomSeed
    }
  }, [customColors, seed, src])

  const handleLoad = useCallback(() => {
    setIsLoaded(true)
    onLoad?.()
  }, [onLoad])

  const handleError = useCallback((event: React.SyntheticEvent<HTMLImageElement>) => {
    setHasError(true)
    setIsLoaded(true)
    onError?.(event.nativeEvent)
  }, [onError])

  const handleImageRef = useCallback((img: HTMLImageElement | null) => {
    if (img && img.complete && img.naturalHeight !== 0) {
      setIsLoaded(true)
    }
  }, [])

  const containerStyle = useMemo(() => ({
    width: typeof width === 'number' ? `${width}px` : width,
    height: typeof height === 'number' ? `${height}px` : height
  }), [width, height])

  return (
    <div
      className={cn("relative overflow-hidden rounded-lg", className)}
      style={containerStyle}
      role="img"
      aria-label={alt}
    >
      {!hasError && (
        <div
          className={cn(
            "absolute inset-0 transition-opacity duration-500",
            isLoaded ? 'opacity-0' : 'opacity-100'
          )}
          style={{ filter: blurIntensity }}
          aria-hidden="true"
        >
          <SVGPattern
            colors={patternData.colors}
            uniqueId={patternData.uniqueId}
            randomSeed={patternData.randomSeed}
          />
        </div>
      )}

      {!hasError && (
        <img
          ref={(img) => {
            if (imgRef.current) imgRef.current = img;
            handleImageRef(img);
          }}
          src={src}
          alt={alt}
          onLoad={handleLoad}
          onError={handleError}
          className={cn(
            "absolute inset-0 w-full h-full transition-opacity duration-500",
            isLoaded ? 'opacity-100' : 'opacity-0'
          )}
          style={{ objectFit }}
          loading={loading}
          decoding="async"
        />
      )}

      {hasError && (
        <div className="absolute inset-0 flex items-center justify-center bg-muted z-10">
          {fallbackComponent ? (
            fallbackComponent
          ) : (
            <div className="text-center text-muted-foreground">
              <svg
                className="mx-auto h-12 w-12 mb-2"
                fill="none"
                stroke="currentColor"
                viewBox="0 0 24 24"
                aria-hidden="true"
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  strokeWidth={2}
                  d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
                />
              </svg>
              <p className="text-sm">Failed to load image</p>
            </div>
          )}
        </div>
      )}
    </div>
  )
}

Installation

npx shadcn@latest add @rigidui/image-loader

Usage

import { ImageLoader } from "@/components/image-loader"
<ImageLoader />