Skip to content

Image loader

The placeholder for AI image generation. a grid of tiny dots that sparkle or weave, until the finished image fades in.

Uses
WebGL
motion

Code

import { useEffect, useRef, useState, type ComponentProps, type CSSProperties } from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@manikrana/ui/lib/utils";
import { DotGrid, type DotGridOptions } from "./dot-grid";

export type ImageLoaderStatus = "generating" | "done";

export type ImageLoaderProps = Omit<ComponentProps<"div">, "children"> & {
  status: ImageLoaderStatus;
  src?: string;
  alt: string;
  aspectRatio?: number;
  grid?: DotGridOptions;
};

const ENTER = { duration: 0.34, ease: [0.32, 0.72, 0, 1] } as const;
const REVEAL = { duration: 0.12, ease: "easeInOut" } as const;

export function ImageLoader({
  status,
  src,
  alt,
  aspectRatio = 1,
  grid,
  className,
  style,
  ...props
}: ImageLoaderProps) {
  const reducedMotion = useReducedMotion();

  return (
    <div
      data-slot="image-loader"
      data-status={status}
      aria-busy={status === "generating"}
      style={{ ...style, "--image-loader-ratio": aspectRatio } as CSSProperties}
      className={cn("w-full max-w-[calc(50dvh*var(--image-loader-ratio))]", className)}
      {...props}
    >
      <motion.div
        initial={
          status === "generating" && !reducedMotion ? { opacity: 0, y: 6, scale: 0.97 } : false
        }
        animate={{ opacity: 1, y: 0, scale: 1 }}
        transition={ENTER}
        className={cn(
          "relative aspect-(--image-loader-ratio) w-full overflow-hidden rounded-3xl",
          status === "done" && "bg-white/4",
        )}
      >
        {status === "generating" ? (
          <DotGrid {...grid} className="absolute inset-0" />
        ) : (
          src && <Reveal key={src} src={src} alt={alt} />
        )}
      </motion.div>
    </div>
  );
}

function Reveal({ src, alt }: { src: string; alt: string }) {
  const ref = useRef<HTMLImageElement>(null);
  const [decoded, setDecoded] = useState(false);

  // decode() also settles for an image that loaded before hydration, whose load event React missed.
  useEffect(() => {
    let current = true;
    ref.current?.decode().then(
      () => {
        if (current) setDecoded(true);
      },
      () => {},
    );
    return () => {
      current = false;
    };
  }, []);

  return (
    <motion.img
      ref={ref}
      src={src}
      alt={alt}
      initial={{ opacity: 0 }}
      animate={{ opacity: decoded ? 1 : 0 }}
      transition={REVEAL}
      className="size-full object-cover"
    />
  );
}