Media Card
A linked card led by its image. The image dissolves into the page at its foot and the title rides the fade, with an optional meta line under it. For a post, a project or a release in a feed or a row.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/media-card.jsonUsage
import { MediaCard } from "@/components/ui/media-card"Examples
Default
One card leading with its image. Pass your router's link through render and your framework's image in its fill mode through media.
A row
pinToFoot lines the badges up when titles wrap to different lengths. The shape changes per breakpoint through --media-card-ratio, so the title's pull-up follows it.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| media* | ReactNode | - | The image. A plain <img> is stretched to cover; a framework image should use its fill mode. |
| title* | ReactNode | - | The title, clamped to three lines and pulled up over the faded foot of the image. |
| description | ReactNode | - | A line of copy under the title, 4px below it. Copy goes here, not in meta. |
| meta | ReactNode | - | A line under the title and description, 16px below them. Most often a Badge. |
| aspect | "landscape" | "portrait" | "square" | "wide" | "landscape" | The image's shape: 3:2, 3:4, 1:1 or 16:9. For a shape that changes per breakpoint, set --media-card-ratio (a number, width over height) in className. |
| fade | number | 0.5 | How much of the image's height dissolves into the page, from 0 to 1. The blur and the title's pull-up scale with it; 0 turns both off. |
| titleSize | "sm" | "md" | "lg" | "md" | Title type: text-lg, text-xl or text-3xl. Use lg for a lead card on its own. |
| level | 2 | 3 | 4 | 5 | 6 | 3 | Heading level of the title. |
| pinToFoot | boolean | false | Pins the title and meta to the card's foot, so the meta lines up across a row. The card must stretch to the row's height. |
| outline | boolean | true | The image's 1px hairline edge, which keeps a dark image's shape on a dark page. Turn it off for large, dark images, where the fade already separates image from page and the edge reads as a frame left open at the foot. |
| render | ReactElement | <a /> | The link element, for a router's link: render={<Link href="/posts/one" />}. |
media*ReactNodeThe image. A plain <img> is stretched to cover; a framework image should use its fill mode. |
| title*ReactNodeThe title, clamped to three lines and pulled up over the faded foot of the image. |
descriptionReactNodeA line of copy under the title, 4px below it. Copy goes here, not in meta. |
metaReactNodeA line under the title and description, 16px below them. Most often a Badge. |
aspect"landscape" | "portrait" | "square" | "wide" = "landscape"The image's shape: 3:2, 3:4, 1:1 or 16:9. For a shape that changes per breakpoint, set --media-card-ratio (a number, width over height) in className. |
| fadenumber = 0.5How much of the image's height dissolves into the page, from 0 to 1. The blur and the title's pull-up scale with it; 0 turns both off. |
titleSize"sm" | "md" | "lg" = "md"Title type: text-lg, text-xl or text-3xl. Use lg for a lead card on its own. |
| level2 | 3 | 4 | 5 | 6 = 3Heading level of the title. |
| pinToFootboolean = falsePins the title and meta to the card's foot, so the meta lines up across a row. The card must stretch to the row's height. |
| outlineboolean = trueThe image's 1px hairline edge, which keeps a dark image's shape on a dark page. Turn it off for large, dark images, where the fade already separates image from page and the edge reads as a frame left open at the foot. |
renderReactElement = <a />The link element, for a router's link: render={<Link href="/posts/one" />}. |
"use client"
import * as React from "react"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/registry/lib/utils"
// The frame's shape, as a number so the title's pull-up can do arithmetic on
// it. The prop lands in `--media-card-aspect` as an inline style, and both the
// frame and the pull read `--media-card-ratio` first, falling back to it. An
// inline custom property would beat any class, so the override needs its own
// name: a responsive shape is then one class on the card
// (`md:[--media-card-ratio:0.75]`) and the pull follows it.
const MEDIA_CARD_ASPECT = {
landscape: 3 / 2,
portrait: 3 / 4,
square: 1,
wide: 16 / 9,
} as const
const MEDIA_CARD_TITLE = {
sm: "text-lg",
md: "text-xl",
lg: "text-3xl",
} as const
// Four stacked backdrop blurs, each stronger and each starting lower, so the
// photograph softens progressively toward its foot instead of along one hard
// band. A layer blurs everything painted before it, the earlier layers
// included, so the strength compounds down the strip.
const BLUR_LAYERS = [1, 2, 4, 8].map((px, i, all) => {
const start = (i / all.length) * 100
const solid = ((i + 1) / all.length) * 100
const mask = `linear-gradient(to bottom, transparent ${start}%, black ${solid}%)`
return { backdropFilter: `blur(${px}px)`, WebkitBackdropFilter: `blur(${px}px)`, maskImage: mask, WebkitMaskImage: mask }
})
// The dissolve into the page: the image itself is masked from opaque at the
// top of the fade to clear at its foot, eased with smoothstep so it has no
// visible start line, and clear for the last tenth so the frame's bottom edge
// never shows. Masking the image, not painting the page colour over it, means
// there is no second layer whose edge can disagree with the image's: an
// overlay left the image's sides peeking through as a faint line at the foot
// whenever a carousel moved the card by a fraction of a pixel. It also fades
// into whatever is behind the card, not only `--background`.
function fadeMask(fade: number) {
const start = (1 - fade) * 100
const span = fade * 90
const stops = Array.from({ length: 9 }, (_, i) => {
const t = (i + 1) / 9
const alpha = 1 - t * t * (3 - 2 * t)
return `rgb(0 0 0 / ${alpha.toFixed(3)}) ${(start + span * t).toFixed(2)}%`
})
return `linear-gradient(to bottom, black ${start.toFixed(2)}%, ${stops.join(", ")}, transparent 100%)`
}
interface MediaCardProps extends Omit<useRender.ComponentProps<"a">, "title" | "media"> {
/** The image, usually your framework's image component in its fill mode
* (`<Image fill sizes="..." />`). A plain `<img>` is stretched to cover. */
media: React.ReactNode
title: React.ReactNode
/** A line of copy bound to the title, a pair rung below it. Copy goes here,
* never in `meta`, which sits a cluster rung away. */
description?: React.ReactNode
/** Under the title, most often a `Badge`. Keep it non-interactive: the
* whole card is already one link. */
meta?: React.ReactNode
aspect?: keyof typeof MEDIA_CARD_ASPECT
/** How much of the frame's height dissolves into the page, from 0 to 1. */
fade?: number
titleSize?: keyof typeof MEDIA_CARD_TITLE
/** Which heading element the title renders. Defaults to `3`. */
level?: 2 | 3 | 4 | 5 | 6
/** Pins the title and meta to the card's foot, so in a row of cards whose
* titles wrap to different lengths the meta still lines up. The card must
* be stretched to the row's height, which a grid cell does by default. */
pinToFoot?: boolean
/** Draws the image's hairline edge. Defaults to `true`, so a dark image
* keeps its shape on a dark page. Turn it off when the image is large and
* dark against the page, where the fade already separates the two and the
* edge reads as a frame left open at the foot. */
outline?: boolean
}
// @use-when a link to a piece of content led by its image: a post, a project
// or a release in a feed or a row, where the title rides the image's faded
// foot. Not Card, which is a surface with no image, and not SelectionCard,
// which is a form option.
function MediaCard({
media,
title,
description,
meta,
aspect = "landscape",
fade = 0.5,
titleSize = "md",
level = 3,
pinToFoot = false,
outline = true,
render,
className,
style,
...props
}: MediaCardProps) {
const Heading = `h${level}` as "h2" | "h3" | "h4" | "h5" | "h6"
const amount = Math.min(Math.max(fade, 0), 1)
return useRender({
render: render ?? <a />,
props: {
"data-slot": "media-card",
// The card is the inline-size container, so `100cqi` below is its own
// width and the pull can be worked out from the frame's real height.
className: cn(
"group/media-card @container flex flex-col rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring-focus",
className
),
style: {
"--media-card-aspect": MEDIA_CARD_ASPECT[aspect],
"--media-card-fade": amount,
...style,
} as React.CSSProperties,
...props,
children: (
<>
{/* The shape is reserved before the image decodes, so nothing under
it moves. `isolate` keeps the blur and the mask in their own
stacking context. The image and its hairline share one masked
layer, so the hairline dissolves with the image instead of
outlining the part that has gone. */}
<div
data-slot="media-card-media"
className="relative isolate"
style={{ aspectRatio: "var(--media-card-ratio, var(--media-card-aspect))" }}
>
<div
className="absolute inset-0"
style={amount > 0 ? { maskImage: fadeMask(amount), WebkitMaskImage: fadeMask(amount) } : undefined}
>
<div className="absolute inset-0 overflow-hidden rounded-lg *:absolute *:inset-0 *:size-full *:object-cover">
{media}
</div>
{outline && (
<div aria-hidden className="pointer-events-none absolute inset-0 rounded-lg ring-1 ring-inset ring-black/10 dark:ring-white/10" />
)}
</div>
{amount > 0 && (
<div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0" style={{ height: `${amount * 100}%` }}>
{BLUR_LAYERS.map((layer, i) => (
<div key={i} className="absolute inset-0" style={layer} />
))}
</div>
)}
</div>
{/* Pulled up by 40% of the fade's height, where the fade is opaque
enough to read type over: frame height is the card's width over
the ratio. */}
<div
data-slot="media-card-body"
className={cn("relative flex flex-col items-start px-4", pinToFoot && "flex-1 justify-end")}
style={{
marginTop: "calc(-0.4 * var(--media-card-fade) * 100cqi / var(--media-card-ratio, var(--media-card-aspect)))",
gap: "var(--spacing-rhythm-cluster)",
}}
>
{/* The title is untrimmed (line-clamp's clip would cut a trimmed
title's descenders), so the plain pair rung binds the
description to it, not the trim-compensated one. */}
<div className="flex flex-col" style={{ gap: "var(--spacing-rhythm-pair)" }}>
<Heading
className={cn(
MEDIA_CARD_TITLE[titleSize],
"line-clamp-3 font-medium leading-snug text-pretty text-foreground transition-opacity duration-small ease-small group-hover/media-card:opacity-70 group-focus-visible/media-card:opacity-70 motion-reduce:transition-none"
)}
>
{title}
</Heading>
{description && (
<p className="text-sm text-pretty text-muted-foreground">{description}</p>
)}
</div>
{meta}
</div>
</>
),
},
})
}
export { MediaCard, type MediaCardProps }

