A picture slot: reserves the space, lazy-loads, takes a URL, an image with required alt text and a dark-mode file, or your own element, and falls back to artwork if there is no image or it fails.
import { Media } from "@/components/ballmac/media"
// A tiny SVG stands in for your own file, so this example works anywhere.
const screenshot = `data:image/svg+xml;utf8,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 400"><rect width="640" height="400" fill="#eef2ff"/><rect x="32" y="32" width="576" height="48" rx="10" fill="#c7d2fe"/><rect x="32" y="104" width="272" height="264" rx="14" fill="#fff"/><rect x="336" y="104" width="272" height="120" rx="14" fill="#fff"/><rect x="336" y="248" width="272" height="120" rx="14" fill="#a5b4fc"/></svg>'
)}`
function Artwork() {
return <div className="from-muted to-accent absolute inset-0 grid place-items-center bg-gradient-to-br text-sm text-muted-foreground">Your screenshot here</div>
}
export default function MediaDemo() {
return (
<div className="grid w-full max-w-3xl gap-4 sm:grid-cols-3">
<figure className="grid gap-2">
<Media media={screenshot} alt="The dashboard showing this month's revenue" aspect="photo" className="rounded-xl border" />
<figcaption className="text-muted-foreground text-xs">A URL with alt text</figcaption>
</figure>
<figure className="grid gap-2">
<Media media={<div className="bg-primary text-primary-foreground grid size-full place-items-center text-sm">Any element</div>} aspect="photo" className="rounded-xl border" />
<figcaption className="text-muted-foreground text-xs">Your own element</figcaption>
</figure>
<figure className="grid gap-2">
<Media aspect="photo" fallback={<Artwork />} className="rounded-xl border" />
<figcaption className="text-muted-foreground text-xs">No image: the artwork</figcaption>
</figure>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/mediaCopy the source into your project.
components/ballmac/media.tsx// Ballmac UI: Media. https://ui.ballmac.com/components/media "use client" import * as React from "react" import { cn } from "@/lib/utils" /** An image with the text that describes it. `alt` is required: use "" only when the picture is pure decoration. */ export type MediaImage = { src: string alt: string /** Shown instead of `src` in dark mode (when an ancestor has the `dark` class). */ srcDark?: string srcSet?: string sizes?: string /** Intrinsic size, so the browser can reserve space before the file arrives. */ width?: number height?: number /** CSS object-position, for example "top" or "50% 20%". */ position?: string } /** What a slot accepts: an image URL, an image with its alt text, or your own element (a next/image, a video, a component). */ export type MediaSource = string | MediaImage | React.ReactElement const ASPECTS = { square: "1 / 1", video: "16 / 9", photo: "4 / 3", wide: "21 / 9", portrait: "3 / 4", cinema: "2 / 1" } as const export type MediaAspect = "auto" | keyof typeof ASPECTS | `${number}/${number}` | `${number} / ${number}` type MediaProps = Omit<React.ComponentProps<"div">, "children"> & { /** The picture, or your own element. Leave it out to show `fallback`. */ media?: MediaSource | null /** Describes a picture passed as a plain URL. Required for informative images; use "" for decoration. */ alt?: string /** What shows when there is no `media` or the file fails to load. Usually the block's built-in artwork. */ fallback?: React.ReactNode /** Reserves the box before the image loads, so the page does not jump. `auto` keeps the image's own proportions. */ aspect?: MediaAspect /** `cover` fills and crops, `contain` shows all of it, `fill` stretches. */ fit?: "cover" | "contain" | "fill" /** Above the fold: load now and with high priority instead of lazily. */ priority?: boolean /** Fill a parent that has its own size (an absolutely positioned panel) instead of reserving space by aspect ratio. */ fill?: boolean /** A card frame (rounded corners, border, soft shadow) around a real image or element. The built-in artwork keeps its own. */ frame?: boolean /** Called when the file fails to load (the fallback is shown). */ onImageError?: () => void } const FIT = { cover: "object-cover", contain: "object-contain", fill: "object-fill" } as const function normalize(media: MediaProps["media"], alt: string | undefined): MediaImage | null { if (typeof media === "string") { if (alt === undefined && process.env.NODE_ENV !== "production") { console.warn(`Media: "${media}" has no alt text. Pass alt="…" to describe it, or alt="" if it is decoration.`) } return { src: media, alt: alt ?? "" } } if (media && typeof media === "object" && "src" in media) return alt === undefined ? media : { ...media, alt } return null } /** * A picture slot: it holds the space, loads lazily, falls back to the artwork if there is no image or it fails, * and takes a plain URL, an image with alt text (and an optional dark-mode file), or any element. */ function Media({ media, alt, fallback, aspect = "auto", fit = "cover", priority = false, frame = false, fill: fillParent = false, onImageError, className, style, ...props }: MediaProps) { const [failed, setFailed] = React.useState(false) const image = normalize(media, alt) const custom = !image && React.isValidElement(media) ? media : null const failedKey = image ? `${image.src}|${image.srcDark ?? ""}` : "" const [lastKey, setLastKey] = React.useState(failedKey) if (failedKey !== lastKey) { // A new file gets another chance. setLastKey(failedKey) setFailed(false) } const ratio = aspect === "auto" ? undefined : aspect in ASPECTS ? ASPECTS[aspect as keyof typeof ASPECTS] : aspect.replace(/\s*\/\s*/, " / ") const fill = ratio !== undefined || fillParent const showImage = image && !failed const handleError = () => { setFailed(true) onImageError?.() } // Images that failed before React attached its handlers never fire onError again. const check = React.useCallback((node: HTMLImageElement | null) => { if (node && node.complete && node.naturalWidth === 0 && node.currentSrc) { setFailed(true) onImageError?.() } }, [onImageError]) const img = (src: string, extra?: string) => ( <img ref={check} src={src} alt={image!.alt} srcSet={src === image!.src ? image!.srcSet : undefined} sizes={image!.sizes} width={image!.width} height={image!.height} loading={priority ? "eager" : "lazy"} decoding="async" fetchPriority={priority ? "high" : "auto"} onError={handleError} style={image!.position ? { objectPosition: image!.position } : undefined} className={cn(fill ? "absolute inset-0 size-full" : "block h-auto w-full", FIT[fit], extra)} /> ) return ( <div data-slot="media" data-state={showImage ? "image" : custom ? "custom" : "fallback"} className={cn( "relative overflow-hidden data-[state=fallback]:overflow-visible", fill && "bg-muted/40 data-[state=fallback]:bg-transparent", frame && "data-[state=custom]:rounded-2xl data-[state=custom]:border data-[state=custom]:shadow-[0_30px_80px_-40px_rgb(0_0_0/0.35)] data-[state=image]:rounded-2xl data-[state=image]:border data-[state=image]:shadow-[0_30px_80px_-40px_rgb(0_0_0/0.35)]", custom && "[&>img]:absolute [&>img]:inset-0 [&>img]:size-full [&>img]:object-cover", className )} style={{ ...(ratio ? { aspectRatio: ratio } : {}), ...style }} {...props} > {showImage ? ( image.srcDark ? ( <> {img(image.src, "dark:hidden")} {img(image.srcDark, "hidden dark:block")} </> ) : ( img(image.src) ) ) : custom ? ( custom ) : ( fallback ?? null )} </div> ) } /** True for an image URL or a `{ src, alt }` object, false for an element or nothing: lets a slot that used to take any element accept pictures too. */ function isMediaImage(value: unknown): value is string | MediaImage { return typeof value === "string" || (typeof value === "object" && value !== null && !React.isValidElement(value) && "src" in value) } export { Media, isMediaImage, type MediaProps }Update the import paths to match your project setup.
Usage
import { Media, isMediaImage } from "@/components/ballmac/media"The full example is in the Code tab above.
Examples
A different file in dark mode
Switch the theme to swap the file.
import { Media } from "@/components/ballmac/media"
const svg = (bg: string, fg: string, label: string) =>
`data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360"><rect width="640" height="360" fill="${bg}"/><rect x="40" y="40" width="560" height="64" rx="12" fill="${fg}" opacity=".18"/><text x="320" y="210" font-family="sans-serif" font-size="28" text-anchor="middle" fill="${fg}">${label}</text></svg>`)}`
export default function MediaDark() {
return (
<div className="w-full max-w-xl">
<Media
media={{ src: svg("#f8fafc", "#0f172a", "Light screenshot"), srcDark: svg("#0b1020", "#e2e8f0", "Dark screenshot"), alt: "The inbox in the current colour scheme" }}
aspect="video"
className="rounded-xl border"
/>
<p className="text-muted-foreground mt-2 text-xs">Switch the theme to swap the file.</p>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
mediaThe picture, or your own element. Leave it out to show `fallback`. | MediaSource | null | — |
altDescribes a picture passed as a plain URL. Required for informative images; use "" for decoration. | string | — |
fallbackWhat shows when there is no `media` or the file fails to load. Usually the block's built-in artwork. | React.ReactNode | — |
aspectReserves the box before the image loads, so the page does not jump. `auto` keeps the image's own proportions. | MediaAspect | "auto" |
fit`cover` fills and crops, `contain` shows all of it, `fill` stretches. | "cover" | "contain" | "fill" | "cover" |
priorityAbove the fold: load now and with high priority instead of lazily. | boolean | false |
fillFill a parent that has its own size (an absolutely positioned panel) instead of reserving space by aspect ratio. | boolean | — |
frameA card frame (rounded corners, border, soft shadow) around a real image or element. The built-in artwork keeps its own. | boolean | false |
onImageErrorCalled when the file fails to load (the fallback is shown). | () => void | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | Informative images need alt text; alt="" marks decoration. A URL without alt logs a warning in development |
| Layout | A fixed aspect ratio reserves the space, so nothing jumps when the file arrives |
Use with AI
Pass media as a URL (with alt), an object { src, alt, srcDark? }, or any element such as a next/image. aspect reserves the box (video, photo, square, wide, portrait or '3/2'); fit is cover | contain | fill; priority loads an above-the-fold image eagerly. fallback shows when there is no media or the file fails. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Media (@ballmac/media) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Any place a product screenshot, photo, cover or avatar goes
- Blocks and templates that ship with generated artwork but should accept the buyer's own image
Not for
- Icons (use an icon component)
- Animated or interactive content that is not an image: pass it as an element
Registry JSON: https://ui.ballmac.com/r/media.json
Credits
Free to use in personal and commercial projects.
- npm
- None
- Registry
- shadcn/utils
Pairs well with
Hero 1: split with product visual
Split hero: badge, word-by-word headline, two calls to action and proof points on the left, a live product summary card on the right, over an animated grid.
Features 5: scrolling product tour
Numbered steps on the left; as each scrolls into view, a sticky panel on the right crossfades to its picture. On phones every step carries its own picture.
Blog 1: featured post with filterable grid
A blog index with a large featured post, topic filter chips and a three-column grid. Posts without a photo get a generated cover drawn from theme tokens, in five compositions.
Accordion
Vertically stacked disclosure sections on Radix Accordion, single or multiple open, with hairline dividers, a rotating plus and height animation.