## 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 ( ); } ``` ### 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 (
{placeholder ? ( // eslint-disable-next-line @next/next/no-img-element ) : null} {status === "error" ? ( ) : null}
); } ```