An image that never shows a broken frame — a missing src, a 404 or a mixed-content block all resolve to an elevated fallback, not a flat placeholder.




The same missing cover at two depths — the fallback is a surface, so it steps with its substrate.
in a card
in a detail dialog
pnpm dlx shadcn@latest add https://matos-ui.com/r/cover-image.jsonimport { CoverImage } from '@/components/matos-ui/cover-image'<CoverImage src={book.imageLinks?.thumbnail} alt={book.title} />alt is required — it names the cover whether or not the image ever loads.
Four things end up in the same place: a missing src, a 404, a mixed-content
block, and an image that decodes to nothing (naturalWidth === 0 even after
load fires). None of them show the browser's broken-image icon — they all
resolve to the fallback.
And the fallback is not a fixed bg-muted. It is Elevated offset={1} over
whatever substrate the cover sits on. The same empty cover in a table cell, a
card, and a detail dialog each reads as a surface at its own depth — §2.1, in a
component every catalogue app needs.
| State | What shows | Tier |
|---|---|---|
loading (no placeholder) | An Elevated skeleton on a slow, ambient pulse | receding, easeInOut over three duration.slower beats |
loading (with placeholder) | The blurDataURL / colour, blurred, underneath | — |
loaded | The image crossfades in with a blur→sharp focus pull (revealVariants) | fast |
empty / error | An Elevated fallback with an icon | — |
prefers-reduced-motion keeps the crossfade and drops the pulse and the blur.
sanitizeGoogle Books thumbnails arrive over http:// (blocked as mixed content on an
https page) and with &edge=curl, which paints a folded-corner border; zoom=1
returns a postage stamp. sanitize forces https, drops edge=curl and bumps the
zoom to 2.
<CoverImage src={dirtyUrl} alt={title} sanitize />It is a pure exported function — use it on its own, and a URL that matches nothing comes back untouched, so it is safe to leave on for a whole list from a mixed source.
import { sanitizeCoverUrl } from '@/components/matos-ui/cover-image'A book cover, an avatar, a product thumbnail — swap ratio and radius. Every
grubby API returns imageLinks half-missing.
<CoverImage src={user.avatarUrl} alt={user.name} ratio="square" radius="lg" />| Prop | Type | Default | Description |
|---|---|---|---|
src | string | null | — | The image URL — may be missing, may be dirty. |
alt | string | — | Required. Names the cover whether it loads or not. |
sanitize | boolean | false | Fix http:// and Google Books borders before loading. |
placeholder | string | — | A blurDataURL or a CSS colour, shown blurred under the image. |
fallback | ReactNode | icon | What fills the frame when there is no usable image. |
ratio | "book" | "portrait" | "square" | "video" | "wide" | "book" | Frame aspect ratio. |
radius | "none" | "sm" | "md" | "lg" | "md" | — |
fit | "cover" | "contain" | "cover" | — |
loading | "lazy" | "eager" | "lazy" | — |
onStatusChange | (status) => void | — | "empty" | "loading" | "loaded" | "error". |
(url: string) => string — forces https, drops &edge=curl, swaps zoom=1 for
zoom=2 on Google Books URLs. Idempotent and generic.
Install Matos UI
Choose a package manager and copy one command for every component.