← ui

image

ui

every picture: reserved space, a blur or a fade until it has loaded, any loader

npx shadcn add @ag/image
Already installed? Preview an update
npx shadcn add @ag/image --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-14
  • patience: how long a source may take once asked for before the next in fallbacks is tried. A host whose network is blocked hangs rather than fails (a minute or more), so the chain never moved on; with patience it degrades to the next copy as an error would: <Image src={cdnUrl} fallbacks={["/local/copy.webp"]} patience={1500} />.
Updated 2026-10-14 · release notes

also called lqip

shelf media

used by video, contact, contacts-app, avatar, quote, flag, player, film-stills, web-preview, github, tweet, youtube, browser

the demo above, verbatim

image.demo.tsx
"use client"

import { Sample } from "@/app/samples"
// #region asset
import river from "@/public/glass-river.jpg"
// #endregion
import { Image } from "@/registry/base-nova/ui/image"
import { AnimatedImage } from "@/registry/base-nova/ui/image-animation"

export default function Demo() {
  return (
    <div className="space-y-8">
      <Sample
        name="animation"
        title="a native GIF cover"
        description="requested on hover/focus; touch uses visibility"
      >
        <AnimatedImage
          controls
          src="/preview-animation.gif"
          poster="/video-poster.jpg"
          width={160}
          height={90}
          alt="Animated bunny"
          className="w-80 rounded-xl"
        />
      </Sample>
      <p className="text-sm text-muted-foreground">
        A loader resizes the picture: this site sets nextImageLoader once with
        ImageLoaderProvider, and any host can pass its own. Without one the
        source loads as it is. Static imports supply the size and blur preview;
        for remote assets, prepare those facts when uploading.
      </p>
      <Sample
        name="natural"
        glance
        with="asset"
        title="intrinsic size"
        description="capped by the available width"
      >
        <Image
          src={river}
          width={240}
          height={160}
          alt="Small river illustration"
          sizes="240px"
          className="rounded-xl"
        />
      </Sample>
      <Sample
        name="responsive"
        with="asset"
        title="reserved space"
        description="blur preview, decoded pixels"
      >
        <Image
          src={river}
          alt="A river winding between mountains"
          sizes="(max-width: 768px) 100vw, 800px"
          className="w-full rounded-2xl"
        />
      </Sample>
      <Sample
        name="contain"
        with="asset"
        title="contain"
        description="placeholder and image share the same fit"
      >
        <div className="relative aspect-square max-w-sm">
          <Image
            src={river}
            alt="Entire river landscape"
            fill
            sizes="384px"
            fit="contain"
            className="rounded-xl"
          />
        </div>
      </Sample>
      <Sample
        name="crop"
        with="asset"
        title="a fixed crop"
        description="the parent sets the box, the picture fills it"
      >
        <div className="relative aspect-square max-w-sm">
          <Image
            src={river}
            alt="River valley, cropped square"
            fill
            sizes="(max-width: 384px) 100vw, 384px"
            className="rounded-2xl object-center"
          />
        </div>
      </Sample>
      <Sample
        name="patience"
        title="a CDN that hangs, then the origin"
        description="the first source never answers (a blocked network holds the connection open rather than failing); after patience, 1.5 s, the copy served by the site itself is shown instead."
      >
        <Image
          // A non-routable address: the request hangs, as a blocked CDN's does.
          src="https://10.255.255.1/lake.jpg"
          fallbacks={["/lightbox/lake.jpg"]}
          patience={1500}
          alt="A lake at dawn"
          width={1200}
          height={800}
          unoptimized
          className="w-full max-w-md rounded-2xl"
        />
      </Sample>
    </div>
  )
}

the source, every file an install writes

image.tsx
"use client"

// A PICTURE THAT ARRIVES: one `<img>`, held until it has really loaded, then faded
// in, with its box reserved from the first paint. No framework: whoever serves the
// pixels at a width is a loader (`ImageLoader`), set once for a site
// (`ImageLoaderProvider`; a Next host wraps its layout in `NextImageLoaderProvider`)
// or on one picture.
// Without one the source loads as it is. Every `<img>` in this registry is this.

import type * as React from "react"
import {
  createContext,
  type Ref,
  use,
  useEffect,
  useRef,
  useState,
} from "react"
import { preload } from "react-dom"
import { cn } from "@/lib/utils"

/** A source at a width: the URL that serves it, or `null` where nothing can resize
 *  it (an SVG, a data URL, another origin) and the source loads as it is. */
export type ImageLoader = (request: {
  src: string
  width: number
  quality: number
}) => string | null

/** The widths a loader is asked for: Next's (`imageSizes` then `deviceSizes`, its
 *  defaults), which its optimizer accepts and no other. A loader of your own takes
 *  them too, so a CDN's cache holds sixteen renditions of a picture at most. */
const SMALL = [16, 32, 48, 64, 96, 128, 256, 384]
const DEVICE = [640, 750, 828, 1080, 1200, 1920, 2048, 3840]
const WIDTHS = [...SMALL, ...DEVICE]

/** Next's optimizer, on the host's own files (`/public`, a static import). A remote
 *  picture needs `images.remotePatterns` in next.config; write a loader that says
 *  which hosts you allowed. */
export const nextImageLoader: ImageLoader = ({ src, width, quality }) =>
  !src.startsWith("/") || src.startsWith("//") || /\.svg($|\?)/.test(src)
    ? null
    : `/_next/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality}`

const LoaderContext = createContext<ImageLoader | null>(null)

/** The site's loader, for every picture under it that names none. */
export function ImageLoaderProvider({
  loader,
  children,
}: {
  loader: ImageLoader
  children: React.ReactNode
}) {
  return <LoaderContext value={loader}>{children}</LoaderContext>
}

/** Next's optimizer for every picture under it, from a server layout as well (a
 *  function cannot cross into the client as a prop; a loader of your own is set
 *  from a client file of yours). */
export function NextImageLoaderProvider({
  children,
}: {
  children: React.ReactNode
}) {
  return <LoaderContext value={nextImageLoader}>{children}</LoaderContext>
}

/** What a static import carries (Next's `StaticImageData`, any bundler's alike):
 *  its size and a blur, for free. */
export type StaticImage = {
  src: string
  width: number
  height: number
  blurDataURL?: string
}

/** Where a picture stands in its arrival. None until the page is live: only React
 *  can mark a picture ready, so one held from the server's HTML would wait for
 *  hydration however early it landed (a cached poster, there in 0.4 s, stayed blank
 *  for seconds on a heavy page). The first paint holds nothing and the browser
 *  paints each picture as it lands; once live, one still on its way is held and
 *  fades in, one already there is ready. Read from the element, since a load that
 *  finished before hydration fired before React was listening. */
export type Arrival = "loading" | "ready" | "error" | undefined

/** How a picture arrives: held clear and a little blurred until it is decoded, then
 *  it fades and sharpens in, never under reduced motion's blur. A picture that never
 *  arrives stays clear: the browser's broken glyph and its alt text are not a
 *  picture, and what stands behind (the host's tone, its stand-in) is the honest
 *  face; the alt stays for a screen reader. */
const ARRIVAL =
  "transition-[opacity,filter] duration-300 ease-out motion-reduce:transition-none data-[state=loading]:opacity-0 motion-safe:data-[state=loading]:blur-sm"

export type ImageProps = Omit<
  React.ImgHTMLAttributes<HTMLImageElement>,
  "src" | "width" | "height"
> & {
  src: string | StaticImage
  /** Its size in pixels: the box is reserved from these before a byte arrives, and
   *  without `sizes` the loader is asked for this width and twice it. A static
   *  import says them itself. */
  width?: number
  height?: number
  /** Fill the positioned parent instead, its box the parent's. */
  fill?: boolean
  /** How wide it is drawn, as the `sizes` attribute says it ("(max-width: 640px)
   *  100vw, 33vw"): the browser picks the rendition from it. */
  sizes?: string
  /** How the pixels (and the blur under them) meet a box of another shape. */
  fit?: "cover" | "contain"
  /** The picture the page is about, above the fold: asked for first, preloaded,
   *  never lazy, never faded. */
  priority?: boolean
  quality?: number
  /** Overrides the site's loader for this picture; `unoptimized` loads it as it is. */
  loader?: ImageLoader
  unoptimized?: boolean
  /** A tiny picture of it (prepare-media's, a static import's), drawn blurred under
   *  it until it lands. With one the picture paints over its blur, no fade. */
  blurDataURL?: string
  /** Where else the picture is, tried in order when one fails or comes back too
   *  small (`minWidth`): a host's sizes, largest first (YouTube's maxres, then hq).
   *  None arriving is the error face. */
  fallbacks?: string[]
  /** The narrowest a picture may be, in its natural pixels, to count as arrived. A
   *  host that answers a missing size with a stand-in (YouTube's 120 px grey for a
   *  maxres it never made) is told apart by this, and the next source is tried. */
  minWidth?: number
  /** How long a source may take to arrive once it is asked for, ms, before the next
   *  in `fallbacks` is tried: a host that hangs rather than fails (a CDN whose
   *  network is blocked holds the connection open until the browser gives up, a
   *  minute or more) then degrades to the next copy as an error would. Unset, a
   *  source is waited for. The last source is always waited for. */
  patience?: number
  ref?: Ref<HTMLImageElement>
}

/** The picture. A new source is a new arrival. */
export function Image(props: ImageProps) {
  const src = typeof props.src === "string" ? props.src : props.src.src
  return <ImageSources key={src} {...props} />
}

/** The renditions of a source the browser may choose from. With `sizes` (a fill
 *  without them is drawn 100vw), every width at least as wide as the narrowest the
 *  page can draw it (its smallest vw share of the narrowest phone, Next's rule);
 *  with a width alone, it and twice it, for the screen's density. Told neither, the
 *  source loads as it is: no width can be chosen from nothing. What Image draws,
 *  for whoever needs the same candidates elsewhere (a lightbox's full view). */
export function imageRenditions(
  src: string,
  loader: ImageLoader,
  {
    quality = 75,
    width,
    sizes,
    fill,
  }: { quality?: number; width?: number; sizes?: string; fill?: boolean },
): { src: string; srcSet: string } | null {
  const at = (w: number) => loader({ src, width: w, quality })
  if (!sizes && !fill && !width) return null
  if (sizes || fill || !width) {
    const shares = [...(sizes ?? "100vw").matchAll(/(?:^|\s)(\d{1,3})vw/g)].map(
      (m) => Number(m[1]) / 100,
    )
    const floor = shares.length
      ? (DEVICE[0] as number) * Math.min(...shares)
      : 0
    const urls = WIDTHS.filter((w) => w >= floor).map(
      (w) => [at(w), w] as const,
    )
    if (urls.some(([url]) => url === null)) return null
    return {
      src: urls.at(-1)?.[0] as string,
      srcSet: urls.map(([url, w]) => `${url} ${w}w`).join(", "),
    }
  }
  const fit = (w: number) =>
    WIDTHS.find((s) => s >= w) ?? (WIDTHS.at(-1) as number)
  const one = at(fit(width))
  const two = at(fit(width * 2))
  if (one === null || two === null) return null
  return { src: two, srcSet: `${one} 1x, ${two} 2x` }
}

/** The blur drawn under the picture, as an SVG of the tiny one (Next's): blurred and
 *  its edges kept opaque, so it fills the box instead of fading at the rim. */
function blurBackground(
  blur: string,
  fit: "cover" | "contain",
  w: number | undefined,
  h: number | undefined,
): React.CSSProperties {
  const box = w && h ? `viewBox='0 0 ${w} ${h}'` : ""
  const aspect = box
    ? "none"
    : fit === "contain"
      ? "xMidYMid"
      : "xMidYMid slice"
  const svg = `<svg xmlns='http://www.w3.org/2000/svg' ${box}><filter id='b' color-interpolation-filters='sRGB'><feGaussianBlur stdDeviation='20'/><feColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/><feFlood x='0' y='0' width='100%' height='100%'/><feComposite operator='out' in='s'/><feComposite in2='SourceGraphic'/><feGaussianBlur stdDeviation='20'/></filter><image width='100%' height='100%' x='0' y='0' preserveAspectRatio='${aspect}' style='filter: url(#b);' href='${blur}'/></svg>`
  return {
    backgroundImage: `url("data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}")`,
    backgroundSize: fit,
    backgroundPosition: "center",
    backgroundRepeat: "no-repeat",
  }
}

/** What a loaded picture is: arrived, or not this source after all (a stand-in too
 *  narrow, or nothing), judged the same whether it landed before React was
 *  listening (read in the ref) or after (the load event). */
function judge(el: HTMLImageElement, minWidth: number): "arrived" | "missing" {
  return el.naturalWidth > 0 && el.naturalWidth >= minWidth
    ? "arrived"
    : "missing"
}

function ImageSources({
  src: given,
  width,
  height,
  fill,
  sizes,
  fit = "cover",
  priority,
  quality,
  loader,
  unoptimized,
  blurDataURL,
  fallbacks = [],
  minWidth = 0,
  patience,
  loading,
  className,
  style,
  onLoad,
  onError,
  ref,
  ...props
}: ImageProps) {
  const site = use(LoaderContext)
  const data = typeof given === "string" ? null : given
  const w = width ?? data?.width
  const h = height ?? data?.height
  const blur = blurDataURL ?? data?.blurDataURL
  const sources = [data?.src ?? (given as string), ...fallbacks]
  const [at, setAt] = useState(0)
  const [state, setState] = useState<Arrival>()
  const source = sources[at] as string
  const load = unoptimized ? null : (loader ?? site)
  const sized =
    load && imageRenditions(source, load, { quality, width: w, sizes, fill })
  const shown = sized ? sized.src : source
  const srcSet = sized ? sized.srcSet : undefined
  const sizesAttr = srcSet?.endsWith("w") ? (sizes ?? "100vw") : undefined
  if (priority)
    preload(shown, {
      as: "image",
      fetchPriority: "high",
      imageSrcSet: srcSet,
      imageSizes: sizesAttr,
    })
  const last = at === sources.length - 1
  // Settles the source shown: arrived, or on to the next, or the error face.
  const settle = (verdict: "arrived" | "missing") => {
    if (verdict === "arrived") return setState("ready")
    if (last) return setState("error")
    setAt(at + 1)
    setState("loading")
  }
  // Patience: the clock starts when the source is asked for (near the viewport, as
  // a lazy picture is), and a source still loading when it runs out is let go.
  const own = useRef<HTMLImageElement | null>(null)
  // biome-ignore lint/correctness/useExhaustiveDependencies: a new source or state is a new wait; `settle` reads the render's own `at`
  useEffect(() => {
    const element = own.current
    if (patience === undefined || last || state !== "loading" || !element)
      return
    let timer = 0
    const watch = new IntersectionObserver(
      ([entry]) => {
        if (!entry?.isIntersecting || timer) return
        timer = window.setTimeout(() => {
          if (!element.complete) settle("missing")
        }, patience)
      },
      { rootMargin: "800px" },
    )
    watch.observe(element)
    return () => {
      watch.disconnect()
      window.clearTimeout(timer)
    }
  }, [patience, last, state, at])
  const attach = (el: HTMLImageElement | null) => {
    own.current = el
    if (el) {
      if (!el.complete) setState("loading")
      else settle(judge(el, minWidth))
    }
    if (typeof ref === "function") ref(el)
    else if (ref) ref.current = el
  }
  const placeholder =
    blur && state !== "ready" ? blurBackground(blur, fit, w, h) : undefined
  // The box's shape from its size, said outright, and never the pixels': the
  // attributes alone give the browser `auto w / h`, and WebKit sizes a broken
  // picture by its broken face whatever the ratio says (a square), unless size
  // containment rules its contents out. A caller's own aspect class wins.
  const shape = !fill && w && h
  return (
    // biome-ignore lint/performance/noImgElement: the one picture element, the loader resizes
    // biome-ignore lint/a11y/useAltText: alt passes through with the rest of the props
    <img
      data-slot="image"
      {...props}
      src={shown}
      srcSet={srcSet}
      sizes={sizesAttr}
      width={fill ? undefined : w}
      height={fill ? undefined : h}
      loading={priority ? "eager" : (loading ?? "lazy")}
      fetchPriority={priority ? "high" : props.fetchPriority}
      decoding={props.decoding ?? "async"}
      ref={attach}
      data-state={state}
      onLoad={(event) => {
        settle(judge(event.currentTarget, minWidth))
        onLoad?.(event)
      }}
      onError={(event) => {
        settle("missing")
        onError?.(event)
      }}
      className={cn(
        fill ? "absolute inset-0 size-full" : "h-auto max-w-full",
        shape && "aspect-(--image-aspect) contain-size",
        fit === "contain" ? "object-contain" : "object-cover",
        !blur && !priority && ARRIVAL,
        "data-[state=error]:opacity-0",
        className,
      )}
      style={
        {
          ...(shape ? { "--image-aspect": `${w} / ${h}` } : {}),
          ...placeholder,
          ...style,
        } as React.CSSProperties
      }
    />
  )
}
image-intent.tsx
"use client"
import { type RefObject, useEffect, useState } from "react"

/** Shared intent for GIF and video covers. Explicit pause survives automatic triggers. */
export function useMediaIntent(
  frame: RefObject<HTMLElement | null>,
  interactionRef?: RefObject<HTMLElement | null>,
) {
  const [intent, setIntent] = useState(false)
  const [visible, setVisible] = useState(false)
  const [pageVisible, setPageVisible] = useState(true)
  const [touch, setTouch] = useState(false)
  const [reduced, setReduced] = useState(true)
  const [manual, setManual] = useState<boolean | null>(null)
  const [activation, setActivation] = useState(0)
  useEffect(() => {
    const root = frame.current
    if (!root) return
    const target = interactionRef?.current ?? root
    const hover = matchMedia("(hover: none)")
    const motion = matchMedia("(prefers-reduced-motion: reduce)")
    const preferences = () => {
      setTouch(hover.matches)
      setReduced(motion.matches)
    }
    const visibility = () =>
      setPageVisible(document.visibilityState === "visible")
    preferences()
    visibility()
    hover.addEventListener("change", preferences)
    motion.addEventListener("change", preferences)
    document.addEventListener("visibilitychange", visibility)
    let pointer = false,
      focus = false
    const update = () => setIntent(pointer || focus)
    const enter = () => {
      if (!pointer) setActivation((n) => n + 1)
      pointer = true
      update()
    }
    const leave = () => {
      pointer = false
      update()
    }
    const focusIn = () => {
      if (!focus) setActivation((n) => n + 1)
      focus = true
      update()
    }
    const focusOut = (event: FocusEvent) => {
      focus =
        event.relatedTarget instanceof Node &&
        target.contains(event.relatedTarget)
      update()
    }
    target.addEventListener("pointerenter", enter)
    target.addEventListener("pointerleave", leave)
    target.addEventListener("focusin", focusIn)
    target.addEventListener("focusout", focusOut)
    const observer = new IntersectionObserver(
      (entries) => {
        const entry = entries[0]
        if (!entry)
          throw new Error("IntersectionObserver fired without an entry")
        setVisible(entry.isIntersecting && entry.intersectionRatio >= 0.5)
      },
      { threshold: [0, 0.5] },
    )
    observer.observe(root)
    return () => {
      observer.disconnect()
      hover.removeEventListener("change", preferences)
      motion.removeEventListener("change", preferences)
      document.removeEventListener("visibilitychange", visibility)
      target.removeEventListener("pointerenter", enter)
      target.removeEventListener("pointerleave", leave)
      target.removeEventListener("focusin", focusIn)
      target.removeEventListener("focusout", focusOut)
    }
  }, [frame, interactionRef])
  return {
    manual,
    activation,
    setManual,
    active: (playOn: "intent" | "visible" = "intent") =>
      visible &&
      pageVisible &&
      (manual ?? (!reduced && (playOn === "visible" || touch || intent))),
  }
}
image-animation.tsx
"use client"
import { type RefObject, useRef } from "react"
import { cn } from "@/lib/utils"
import { Image } from "./image"
import { useMediaIntent } from "./image-intent"

export type AnimatedImageProps = {
  src: string
  poster: string
  width: number
  height: number
  alt: string
  blurDataURL?: string
  className?: string
  imageClassName?: string
  sizes?: string
  playOn?: "intent" | "visible"
  /** Show an optional play/pause button. Covers are unadorned by default. */
  controls?: boolean
  interactionRef?: RefObject<HTMLElement | null>
}

/** Native animated image, loaded only while requested. Stopping returns to its poster.
 * This does not promise frame-accurate pause/seek; use Video for those controls. */
export function AnimatedImage(props: AnimatedImageProps) {
  return <AnimationSource key={props.src} {...props} />
}
function AnimationSource({
  src,
  poster,
  width,
  height,
  alt,
  blurDataURL,
  className,
  imageClassName,
  sizes = "100vw",
  playOn,
  controls = false,
  interactionRef,
}: AnimatedImageProps) {
  const frame = useRef<HTMLDivElement>(null)
  const intent = useMediaIntent(frame, interactionRef)
  const active = intent.active(playOn)
  return (
    <div
      ref={frame}
      tabIndex={interactionRef || controls ? undefined : 0}
      data-slot="image-animation"
      data-playing={active}
      className={cn("not-prose relative max-w-full overflow-hidden", className)}
      style={{ aspectRatio: `${width} / ${height}` }}
    >
      <Image
        src={poster}
        alt={alt}
        fill
        sizes={sizes}
        blurDataURL={blurDataURL}
        className={imageClassName}
      />
      {active && (
        <Image
          src={src}
          alt=""
          aria-hidden
          fill
          unoptimized
          className={imageClassName}
        />
      )}
      {controls && (
        <button
          type="button"
          aria-label={`${active ? "Pause" : "Play"} ${alt}`}
          aria-pressed={active}
          onClick={(e) => {
            e.preventDefault()
            e.stopPropagation()
            intent.setManual(!active)
          }}
          className="absolute right-2 bottom-2 rounded-full bg-black/60 px-3 py-2 text-xs text-white"
        >
          {active ? "pause" : "play"}
        </button>
      )}
    </div>
  )
}