## Blur-up Image — Content
Placeholder resolves into the photo.
Docs: https://www.interior.dev/docs/blur-up-image
Reference: https://www.interior.dev/reference/blur-up-image
License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT)
### Install
Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project.
`bunx shadcn@latest add https://www.interior.dev/r/blur-up-image.json`
Or run `bun add motion` and copy the source below.
### Usage
```tsx
import { BlurUpImage } from "@/components/interior/blur-up-image";
type Shot = {
id: string;
url: string;
lqip: string;
tone: string;
caption: string;
};
export function ProjectGrid({ shots }: { shots: Shot[] }) {
return (
{shots.map((shot, i) => (
{shot.caption}
))}
);
}
```
### Props
- `src` (`string | undefined`)
The full image. Undefined is a legal state — the placeholder holds while the URL is still being resolved, and it is not treated as an error.
- `alt` (`string`)
Required. Lives on the real for the whole lifecycle, so the description is available before the bytes are.
- `width` (`number`)
Intrinsic width. Used with height to reserve the box before anything loads.
- `height` (`number`)
Intrinsic height. Used with width to reserve the box before anything loads.
- `placeholder` (`string`) — default: `undefined`
A tiny LQIP data URI. Blurred statically and never animated; only its opacity and scale move.
- `color` (`string`) — default: `undefined`
Dominant colour painted under everything, for the frames before even the placeholder decodes.
- `blur` (`number`) — default: `14`
Placeholder blur radius in pixels. A constant, applied once, so no frame repaints the filter.
- `radius` (`5 | 6 | 9 | 11 | 14`) — default: `11`
Corner radius, applied inline so a caller's radius always wins and the value stays on the ramp.
- `srcSet` (`string`) — default: `undefined`
Passed through. A change to it restarts load tracking alongside src.
- `sizes` (`string`) — default: `undefined`
Passed through to the .
- `loading` (`"lazy" | "eager"`) — default: `"lazy"`
Native lazy loading. An off-screen image simply stays on its placeholder.
- `fetchPriority` (`"high" | "low" | "auto"`) — default: `undefined`
Passed through for the one image that is the LCP candidate.
- `onReady` (`() => void`) — default: `undefined`
Fired once the bitmap is decoded, not when the load event lands.
- `onError` (`() => void`) — default: `undefined`
Fired when the image fails or decodes to nothing.
- `className` (`string`) — default: `""`
Appended last on the frame.
### Behavior notes
- The box is reserved from width and height as an aspect ratio, so the caption under a photo sits in its final position before a single byte arrives, and the frame keeps that space when the URL is dead instead of collapsing the page.
- An image that is already in the browser cache is detected in a layout effect before paint and revealed with a zero-duration transition, so a cached photo never flashes its own placeholder or plays a fade nobody asked for.
- The reveal waits on decode(), not on the load event, because a load event only promises the bytes arrived — swapping there can hand the compositor an undecoded bitmap and drop the frame the swap happens on.
- The blur is a constant on a static placeholder; only opacity and scale animate, because an animated filter repaints the full image every frame and cannot be composited.
- A failed load lands on a drawn glyph in the same frame at the same size, so a broken URL degrades to a state rather than to a browser default and a reflow.
- Under prefers-reduced-motion the photo replaces the placeholder immediately; nothing is hidden, only the crossfade is skipped, and the alt text is on the real element the whole time so it is available before the image is.
### Source (`components/interior/blur-up-image.tsx`)
```tsx
"use client";
import { useRef, useState } from "react";
import {
motion,
useIsomorphicLayoutEffect,
useReducedMotion,
} from "motion/react";
const DEVELOP = { duration: 0.65, ease: [0.23, 1, 0.32, 1] } as const;
const INSTANT = { duration: 0 } as const;
export type BlurUpStatus = "loading" | "ready" | "error";
export type UseBlurUpImageOptions = {
src?: string;
srcSet?: string;
onReady?: () => void;
onError?: () => void;
};
export function useBlurUpImage({
src,
srcSet,
onReady,
onError,
}: UseBlurUpImageOptions) {
const ref = useRef(null);
const [state, setState] = useState<{
status: BlurUpStatus;
instant: boolean;
}>({ status: "loading", instant: false });
const ready = useRef(onReady);
ready.current = onReady;
const failed = useRef(onError);
failed.current = onError;
useIsomorphicLayoutEffect(() => {
const img = ref.current;
const set = (status: BlurUpStatus, instant: boolean) =>
setState((prev) =>
prev.status === status && prev.instant === instant
? prev
: { status, instant },
);
if (!img || !src) {
set("loading", false);
return;
}
let alive = true;
const cached = img.complete && img.naturalWidth > 0;
const reveal = () => {
if (!alive) return;
set("ready", cached);
ready.current?.();
};
const fail = () => {
if (!alive) return;
set("error", cached);
failed.current?.();
};
if (img.complete) {
if (cached) reveal();
else fail();
return () => {
alive = false;
};
}
set("loading", false);
const onLoad = () => {
if (!alive) return;
if (typeof img.decode === "function") {
img.decode().then(reveal, fail);
return;
}
reveal();
};
img.addEventListener("load", onLoad);
img.addEventListener("error", fail);
return () => {
alive = false;
img.removeEventListener("load", onLoad);
img.removeEventListener("error", fail);
};
}, [src, srcSet]);
return {
ref,
status: state.status,
instant: state.instant,
loaded: state.status === "ready",
};
}
export type BlurUpImageProps = {
src?: string;
alt: string;
width: number;
height: number;
placeholder?: string;
color?: string;
blur?: number;
radius?: 5 | 6 | 9 | 11 | 14;
srcSet?: string;
sizes?: string;
loading?: "lazy" | "eager";
fetchPriority?: "high" | "low" | "auto";
onReady?: () => void;
onError?: () => void;
className?: string;
};
export function BlurUpImage({
src,
alt,
width,
height,
placeholder,
color,
blur = 14,
radius = 11,
srcSet,
sizes,
loading = "lazy",
fetchPriority,
onReady,
onError,
className = "",
}: BlurUpImageProps) {
const reduced = useReducedMotion();
const { ref, status, instant } = useBlurUpImage({
src,
srcSet,
onReady,
onError,
});
const shown = status === "ready";
const still = reduced === true || instant;
const transition = still ? INSTANT : DEVELOP;
return (