All patterns

Image Blur Up

A tiny blurred stand-in holds the frame and sharpens into the full picture, with nothing below it moving.

loadingpremiumelegantautomatic · finite · starter · ~0.9s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

276 lines · react + motion only
import { useEffect, useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Image Blur Up
 *
 * A cheap placeholder — a handful of bytes of gradient, or the tiny
 * base64 thumbnail your image pipeline already emits — sits in the
 * frame at full blur and sharpens as the real file arrives. The frame
 * is sized by aspect ratio from the first paint, so nothing below it
 * moves when the image lands.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * With no `src` the frame renders an inline SVG stand-in, so the file
 * runs with zero props and no assets. Pass `src` for the real thing.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ImageBlurUpProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Full-resolution image. Omitted, an inline SVG stands in for it. */
  src?: string;
  /** Alternative text for the full image. */
  alt?: string;
  /** Any CSS background value: a tiny base64 thumbnail, a dominant-color
   *  gradient, whatever your pipeline produces. */
  placeholder?: string;
  /** Drive this from your own load state. Left undefined, the component
   *  uses the image's own load event, or a timer when there is no src. */
  loaded?: boolean;
  /** Stand-in decode time used only when there is no src, in ms. */
  revealAfterMs?: number;
  /** Frame width — px number or any CSS length. */
  width?: number | string;
  /** Frame aspect ratio, width ÷ height. */
  aspectRatio?: number;
  /** Caption under the frame. */
  caption?: string;
  /** Secondary line under the caption. */
  meta?: string;
  /** Fires once the full image is showing. */
  onLoaded?: () => void;
};

type VariantConfig = {
  /** Starting blur radius on the placeholder, in px. */
  blur: number;
  /** Placeholder overscale — hides the soft edge a blur leaves behind. */
  overscale: number;
  /** How much the sharp layer settles in by, as a scale factor. */
  settleFrom: number;
  sharpenSeconds: number;
  fadeSeconds: number;
  captionDelay: number;
};

// Quality rule: only the picture scales. The caption underneath fades
// on a fixed baseline and at a constant size, because type that grows
// into place reads as a zoom, not as content arriving. Variants differ
// in blur depth and pace — none of them bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a haze. For thumbnail grids where a dozen of these resolve
  // within a second of each other.
  subtle: {
    blur: 10,
    overscale: 1.03,
    settleFrom: 1,
    sharpenSeconds: 0.32,
    fadeSeconds: 0.26,
    captionDelay: 0.06,
  },
  // The all-purpose setting: a clear before and after, over in a third
  // of a second.
  default: {
    blur: 18,
    overscale: 1.06,
    settleFrom: 1.01,
    sharpenSeconds: 0.46,
    fadeSeconds: 0.34,
    captionDelay: 0.1,
  },
  // Deeper blur and a longer sharpen, for a single hero image that owns
  // the top of the page.
  playful: {
    blur: 26,
    overscale: 1.09,
    settleFrom: 1.03,
    sharpenSeconds: 0.62,
    fadeSeconds: 0.42,
    captionDelay: 0.14,
  },
};

/** Theme-adaptive neutral for the card chrome. The picture's own colors
 *  stay literal — they stand in for photography, not for a surface. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** The low-quality stand-in: three colors and a soft falloff, which is
 *  all a downsampled thumbnail amounts to once it has been blurred. */
const DEFAULT_PLACEHOLDER =
  "radial-gradient(62% 72% at 70% 64%, #F3B172 0%, #C4665A 40%, rgba(196,102,90,0) 74%), " +
  "linear-gradient(158deg, #1B2A6B 0%, #4A4DA6 54%, #2A2350 100%)";

export default function ImageBlurUp({
  variant = "default",
  src,
  alt = "Cover image",
  placeholder = DEFAULT_PLACEHOLDER,
  loaded,
  revealAfterMs = 1100,
  width = 300,
  aspectRatio = 8 / 5,
  caption = "Annual review — cover",
  meta = "2400 × 1500 · JPEG",
  onLoaded,
}: ImageBlurUpProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [selfLoaded, setSelfLoaded] = useState(false);
  const gradientId = useId();

  // Uncontrolled and src-less, the file still has to demonstrate itself,
  // so a timer stands in for the network. Either real prop takes over.
  useEffect(() => {
    if (loaded !== undefined || src) return;
    const timer = setTimeout(() => setSelfLoaded(true), revealAfterMs);
    return () => clearTimeout(timer);
  }, [loaded, src, revealAfterMs]);

  const isLoaded = loaded ?? selfLoaded;

  const sharpen = { duration: cfg.sharpenSeconds, ease: [0.22, 0.61, 0.36, 1] as const };

  // Reduced motion keeps both layers and the order they arrive in, and
  // drops the two things that actually move: the blur radius resolving
  // and the frame settling. The placeholder simply holds its blur and
  // cross-fades out under the sharp image.
  const blurPx = isLoaded && !reduceMotion ? 0 : cfg.blur;
  const placeholderScale = isLoaded && !reduceMotion ? 1 : cfg.overscale;

  return (
    <div style={{ width, display: "flex", flexDirection: "column", gap: 10 }}>
      {/* The frame is sized by aspect ratio before a single byte of image
          has arrived. Everything below it is already in its final place,
          which is the difference between a blur-up and a layout shift
          with a blur on top. */}
      <div
        aria-busy={!isLoaded}
        style={{
          position: "relative",
          width: "100%",
          aspectRatio: String(aspectRatio),
          borderRadius: 14,
          overflow: "hidden",
          background: tone(8),
          border: `1px solid ${tone(10)}`,
        }}
      >
        <motion.div
          aria-hidden
          initial={false}
          animate={{
            filter: `blur(${blurPx}px)`,
            scale: placeholderScale,
            opacity: isLoaded ? 0 : 1,
          }}
          transition={{
            filter: sharpen,
            scale: sharpen,
            opacity: {
              duration: cfg.fadeSeconds,
              ease: "easeOut",
              // The placeholder leaves a beat after the sharp layer has
              // started arriving, so the frame is never empty mid-swap.
              delay: isLoaded && !reduceMotion ? cfg.sharpenSeconds * 0.35 : 0,
            },
          }}
          style={{
            position: "absolute",
            inset: 0,
            background: placeholder,
            // Overscaling under a blur keeps the soft edge outside the
            // frame; without it the corners look chewed.
            transformOrigin: "center",
          }}
        />

        <motion.div
          initial={false}
          animate={{
            opacity: isLoaded ? 1 : 0,
            scale: isLoaded || reduceMotion ? 1 : cfg.settleFrom,
          }}
          transition={{
            opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
            scale: sharpen,
          }}
          style={{ position: "absolute", inset: 0 }}
          onAnimationComplete={() => {
            if (isLoaded) onLoaded?.();
          }}
        >
          {src ? (
            <img
              src={src}
              alt={alt}
              onLoad={() => setSelfLoaded(true)}
              style={{
                width: "100%",
                height: "100%",
                objectFit: "cover",
                display: "block",
              }}
            />
          ) : (
            <SampleImage gradientId={gradientId} />
          )}
        </motion.div>
      </div>

      {/* Caption travels nowhere and never changes size — it only fades
          up to full strength once the picture it describes is there. */}
      <motion.div
        initial={false}
        animate={{ opacity: isLoaded ? 1 : 0.38 }}
        transition={{
          duration: cfg.fadeSeconds,
          ease: "easeOut",
          delay: isLoaded && !reduceMotion ? cfg.captionDelay : 0,
        }}
      >
        <div style={{ fontSize: 13, fontWeight: 600, lineHeight: 1.3 }}>
          {caption}
        </div>
        <div style={{ fontSize: 11.5, opacity: 0.55, marginTop: 3 }}>{meta}</div>
      </motion.div>
    </div>
  );
}

/** Stand-in for a photograph so the file needs no asset and no network.
 *  Its colors are literal on purpose: this is a picture, not a surface,
 *  so it must not adapt to the page theme. */
function SampleImage({ gradientId }: { gradientId: string }) {
  const sky = `${gradientId}-sky`;
  const glow = `${gradientId}-glow`;
  return (
    <svg
      viewBox="0 0 320 200"
      preserveAspectRatio="xMidYMid slice"
      style={{ width: "100%", height: "100%", display: "block" }}
      role="img"
      aria-label="Abstract cover image"
    >
      <defs>
        <linearGradient id={sky} x1="0" y1="0" x2="0.7" y2="1">
          <stop offset="0%" stopColor="#16235C" />
          <stop offset="46%" stopColor="#4A4DA6" />
          <stop offset="78%" stopColor="#B65F5C" />
          <stop offset="100%" stopColor="#F0A868" />
        </linearGradient>
        <radialGradient id={glow} cx="0.72" cy="0.62" r="0.42">
          <stop offset="0%" stopColor="#FFD9A0" stopOpacity="0.95" />
          <stop offset="100%" stopColor="#FFD9A0" stopOpacity="0" />
        </radialGradient>
      </defs>
      <rect width="320" height="200" fill={`url(#${sky})`} />
      <rect width="320" height="200" fill={`url(#${glow})`} />
      <circle cx="230" cy="124" r="22" fill="#FFE3B8" opacity="0.9" />
      <path d="M0 152 L86 116 L164 150 L246 108 L320 138 V200 H0 Z" fill="#241C4A" opacity="0.72" />
      <path d="M0 176 L72 148 L150 178 L232 146 L320 172 V200 H0 Z" fill="#150F30" opacity="0.85" />
    </svg>
  );
}

About this pattern

What a good image pipeline looks like from the outside. A few bytes of gradient or a downsampled thumbnail fill the frame immediately at heavy blur; when the full file decodes, the blur resolves to zero, the overscale settles back to one, and the stand-in cross-fades away underneath. The frame is sized by aspect ratio from the first paint, so the caption and everything beneath it are already in their final position — this is what separates a blur-up from a layout shift with a blur painted over it. The placeholder is overscaled slightly while blurred, because a blur radius bleeds past its own edges and un-overscaled corners look chewed.

Article hero imageThumbnail gridAsset libraryProduct photography

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Photo gallery

    Grid tiles hold a dominant-color block that gives way to the photograph, with no reflow.

Related patterns