Skip to content
Primitiv home
Framework
Consumption mode

Card

stableSource Figma

A content container — an optional media region plus a padded content block holding a header, description and footer. layout picks the arrangement: vertical (media above), horizontal (media beside), or cover (media behind the content, under a legibility scrim). Sized xs–xl; data-density scales each size further.

Playground

Density

Preview

Winter light

First light over the Cairngorms, shot on a cold clear morning.

Size
Layout
Elevation
import {  Card, CardMedia, CardContent, CardHeader,  CardTitle, CardDescription, CardFooter,} from "@/components/ui/card";import { Button } from "@/components/ui/button";
<Card layout="vertical" size="md" elevation="flat">  <CardMedia>    <img src="/photo.jpg" alt="" />  </CardMedia>  <CardContent>    <CardHeader>      {/* asChild picks the heading level for your outline; a bare          <CardTitle>…</CardTitle> renders an <h3>. */}      <CardTitle asChild>        <h4>Winter light</h4>      </CardTitle>    </CardHeader>    <CardDescription>First light over the Cairngorms.</CardDescription>    <CardFooter>      <Button size="sm" variant="secondary">Share</Button>      <Button size="sm">View</Button>    </CardFooter>  </CardContent></Card>

Density is set by a data-density ancestor — the Context system, not a Card prop.

Installation

npx primitiv add card

Import

import { Card } from "@/components/ui/card";

No headless primitive — this one ships only as a copied styled surface, so primitiv add is the only way in, whichever mode you are reading.

Anatomy

<Card>  <CardMedia>{/* img / picture / video */}</CardMedia>  <CardContent>    <CardHeader>      <CardTitle>Title</CardTitle>    </CardHeader>    <CardDescription>Body copy.</CardDescription>    <CardFooter>{/* actions */}</CardFooter>  </CardContent></Card>

Props

Card

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
asChildboolean—Render the single child element instead of a wrapping <div>, merging the card classes onto it — e.g. <Card asChild><a href="...">...</a></Card> for a card that is itself one link.
coverForegroundDark"white" | "black""white"The title/description colour while the app is in dark theme. Only has an effect when layout="cover". See coverForegroundLight.
coverForegroundLight"white" | "black""white"The title/description colour while the app is in light theme. Only has an effect when layout="cover". Limited to the two absolute (non-theme-flipping) tones — the scrim itself is always color/absolute-black regardless of theme, so "white" is legible against it in every case except an unusually bright photo. Set this independently of coverForegroundDark: a given photo's legibility doesn't necessarily track the app's own light/dark switch.
layout"vertical" | "horizontal" | "cover"verticalHow the media region relates to the content.
size"xs" | "sm" | "md" | "lg" | "xl"mdCard size; data-density scales each size further.
elevation"flat" | "raised"flatResting shadow depth.
scrim"soft" | "medium" | "strong"mediumStrength of the legibility gradient. Only rendered by layout="cover".

CardMedia

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
childrenReactNode—The image, <picture>, <video> or <svg> to show. It is sized to cover the region, so it needs no sizing of its own.
insetbooleanfalseInset the media from the card edge and round its corners, instead of bleeding it flush to the border. Flush media is deliberately square-cornered — the card's own overflow: hidden supplies the outer radius, so giving the media its own would also round the inner seam where it meets the content and leave a visible notch.

CardContent

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

No props of its own.

CardHeader

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

No props of its own.

CardTitle

Extends HTMLHeadingElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
asChildboolean—Render the single child element instead of a wrapping <h3>, merging the title classes onto it — use this to set the correct heading level for the surrounding document outline.

CardDescription

Extends HTMLParagraphElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
asChildboolean—Render the single child element instead of a wrapping <p>.

CardFooter

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
justify"start" | "center" | "end""end"Horizontal alignment of the footer's actions. Button alignment is a real per-composition decision, so it is a prop rather than a fixed house rule.

Styling contract

--primitiv-card-background--primitiv-card-border-color--primitiv-card-border-width--primitiv-card-radius--primitiv-card-padding--primitiv-card-gap--primitiv-card-shadow--primitiv-card-media-radius-inset--primitiv-card-media-aspect-ratio--primitiv-card-media-inline-size--primitiv-card-media-background--primitiv-card-cover-aspect-ratio--primitiv-card-cover-foreground-light--primitiv-card-cover-foreground-dark--primitiv-card-scrim-color--primitiv-card-scrim-opacity--primitiv-card-scrim-mid--primitiv-card-scrim-end--primitiv-card-title-color--primitiv-card-title-font-family--primitiv-card-title-font-size--primitiv-card-title-font-weight--primitiv-card-title-line-height--primitiv-card-description-color--primitiv-card-description-font-family--primitiv-card-description-font-size--primitiv-card-description-font-weight--primitiv-card-description-line-height

Accessibility

  • A card is a container, not a role. By itself it is a <div> with no semantics — the meaning comes from what you put in it. CardTitle renders a real <h3>, so it lands in the document outline; use asChild on it to pick the level that fits (an <h2> in a section of cards, say) rather than leaving heading levels to jump.
  • Decide what the media announces. A decorative photo takes alt="" (as every example here does) so a screen reader skips it; a meaningful image gets a real alt. CardMedia does not force either — it is your <img>.
  • One card, one link — via asChild on the root. Making the whole card a link is far better than an <a> wrapped around a grid of text and buttons. But a card that is itself a link must contain no other interactive elements: a <button> or second <a> inside a link is invalid and unusable by keyboard. Keep the footer actions for non-link cards.
  • Footer actions are real controls, so order them by importance. CardFooter's justify is visual only; the DOM order is the tab order, so put the primary action first in the markup even if it sits on the right visually.
  • layout and size are presentational and change nothing in the accessibility tree — a cover card reads the same as a vertical one. The scrim and coverForeground* settings are about visual legibility, which is itself an accessibility concern: check the title's contrast against the actual photo, since the scrim only helps so much over a busy image.
  • elevation is decoration. A raised card is not more important to assistive technology than a flat one; if a card is genuinely more significant, say so in its content, don't rely on the shadow.

Examples

A complete card (the anatomy in practice)

The full composition: an optional CardMedia, then a CardContent holding a CardHeader (with the CardTitle), a CardDescription, and a CardFooter of actions. CardContent supplies the padding and the vertical rhythm between those regions — deliberately, so there is one padding box and no doubled seam where regions meet. CardTitle renders an <h3>; use asChild on it to set the heading level that fits your page outline.

Density

Winter light

First light over the Cairngorms, shot on a cold clear morning.

import {  Card, CardMedia, CardContent, CardHeader,  CardTitle, CardDescription, CardFooter,} from "@/components/ui/card";import { Button } from "@/components/ui/button";
<Card>  <CardMedia>    <img src="/photo.jpg" alt="" />  </CardMedia>  <CardContent>    <CardHeader>      {/* asChild picks the heading level for your outline; a bare          <CardTitle>…</CardTitle> renders an <h3>. */}      <CardTitle asChild>        <h4>Winter light</h4>      </CardTitle>    </CardHeader>    <CardDescription>First light over the Cairngorms.</CardDescription>    <CardFooter>      <Button size="sm" variant="secondary">Share</Button>      <Button size="sm">View</Button>    </CardFooter>  </CardContent></Card>

Layouts

layout decides where the media sits relative to the content: "vertical" (the default) stacks media on top, "horizontal" puts it to the side, and "cover" places it behind the content with a legibility scrim. Media and content are the same two siblings in every layout — only the CSS changes — so you switch presentation with one prop, not a different tree.

Density

Winter light

First light over the Cairngorms, shot on a cold clear morning.

Winter light

First light over the Cairngorms, shot on a cold clear morning.

Winter light

First light over the Cairngorms, shot on a cold clear morning.

import {  Card, CardMedia, CardContent, CardHeader,  CardTitle, CardDescription, CardFooter,} from "@/components/ui/card";import { Button } from "@/components/ui/button";
<Card layout="vertical">...</Card><Card layout="horizontal">...</Card><Card layout="cover">...</Card>

Media: flush or inset

CardMedia's inset prop chooses how the image meets the frame. Flush (the default) bleeds the media to the border with square corners — the card's own overflow: hidden supplies the outer radius, and giving the media its own would round the inner seam into a visible notch. inset pulls the media in from the edge and rounds all four of its corners, for the more contained, panel-in-a-panel look.

Density

Winter light

First light over the Cairngorms, shot on a cold clear morning.

Winter light

First light over the Cairngorms, shot on a cold clear morning.

import {  Card, CardMedia, CardContent, CardHeader,  CardTitle, CardDescription, CardFooter,} from "@/components/ui/card";import { Button } from "@/components/ui/button";
{/* flush (default) */}<Card>  <CardMedia>    <img src="/photo.jpg" alt="" />  </CardMedia>  <CardContent>    <CardHeader>      {/* asChild picks the heading level for your outline; a bare          <CardTitle>…</CardTitle> renders an <h3>. */}      <CardTitle asChild>        <h4>Winter light</h4>      </CardTitle>    </CardHeader>    <CardDescription>First light over the Cairngorms.</CardDescription>    <CardFooter>      <Button size="sm" variant="secondary">Share</Button>      <Button size="sm">View</Button>    </CardFooter>  </CardContent></Card>
{/* inset + rounded */}<Card>  <CardMedia inset>    <img src="/photo.jpg" alt="" />  </CardMedia>  <CardContent>    <CardHeader>      {/* asChild picks the heading level for your outline; a bare          <CardTitle>…</CardTitle> renders an <h3>. */}      <CardTitle asChild>        <h4>Winter light</h4>      </CardTitle>    </CardHeader>    <CardDescription>First light over the Cairngorms.</CardDescription>    <CardFooter>      <Button size="sm" variant="secondary">Share</Button>      <Button size="sm">View</Button>    </CardFooter>  </CardContent></Card>

Cover layout and the scrim

Under layout="cover" the content sits over the media, and a scrim gradient keeps it legible against any photo — "soft", "medium" (default) or "strong". The scrim is a pseudo-element on the card, so it costs nothing in the other layouts. The text is coverForegroundLight / coverForegroundDark (both default "white", the only two absolute non-theme-flipping tones) — set them per photo, since a bright image can need dark text regardless of the app's own light/dark mode.

Density

Soft

Scrim strength soft.

Medium

Scrim strength medium.

Strong

Scrim strength strong.

import {  Card, CardMedia, CardContent, CardHeader,  CardTitle, CardDescription, CardFooter,} from "@/components/ui/card";import { Button } from "@/components/ui/button";
<Card layout="cover" scrim="strong" coverForegroundLight="white">  <CardMedia><img src="/photo.jpg" alt="" /></CardMedia>  <CardContent>    <CardHeader>      <CardTitle asChild><h4>Winter light</h4></CardTitle>    </CardHeader>    <CardDescription>First light over the Cairngorms.</CardDescription>  </CardContent></Card>

asChild on the root merges the card onto your own element, so the entire card can be a single link. Prefer this to putting an <a> around a <div> full of other controls: one card, one link, one focus stop. Do not then also put buttons or links inside it — nested interactive elements inside a link are invalid; a card that is itself a link should have no other controls (drop the footer actions).

Density
import {  Card, CardMedia, CardContent, CardHeader,  CardTitle, CardDescription, CardFooter,} from "@/components/ui/card";import { Button } from "@/components/ui/button";
<Card asChild layout="horizontal">  <a href="/photos/winter-light">    <CardMedia inset><img src="/photo.jpg" alt="" /></CardMedia>    <CardContent>      <CardHeader>        <CardTitle asChild><h4>Winter light</h4></CardTitle>      </CardHeader>      <CardDescription>First light over the Cairngorms.</CardDescription>    </CardContent>  </a></Card>