---
title: "Card"
description: "A bounded group of related content with a border and consistent padding is the ordinary box that most of a screen is made of."
url: "https://opsinjs.pensievelabs.org/components/card"
source: "https://opsinjs.pensievelabs.org/components/card.md"
section: "Components"
status: "shipped"
kind: "component"
category: "surfaces"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["panel", "container", "box", "tile group", "content box"]
usedIn: ["choose-a-component", "daily-log-screen", "diabetes-medicines-app", "health-metric-card", "onboarding-screen", "results-screen", "trends-screen"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="card"
  status="shipped"
  questions="[
  &#x22;A link card may hold no other interactive element, but the rule is stated, not enforced: TypeScript and a runtime walk both miss wrapped controls.&#x22;,
  &#x22;Nested cards are announced no differently from unnested ones, and how deep is acceptable is unanswered.&#x22;,
  &#x22;A link card's accessible name is its whole text content; whether Card should offer a way to name the link is open.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="card" />

## Installation [#installation]

<ComponentInstall name="card" unbuilt="false" importPath="@/components/ui/card" dependencies="[&#x22;lucide-react&#x22;]" registryDependencies="[&#x22;surface&#x22;]" />

## Usage [#usage]

```tsx
import { Card } from "@/components/ui/card"
```

```tsx
<Card>
  <Card.Header title={<h3>Recent readings</h3>} />
  <Card.Body>Card content</Card.Body>
</Card>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Grouping content that belongs together and needs a visible boundary.&#x22;,
  &#x22;Giving a block its own padding, radius and rung so it reads as one thing, not a region of the page.&#x22;,
  &#x22;As the base for a purpose-built card with its own contract, the way ResultCard and CareCard are.&#x22;,
]"
  avoid="[
  { case: &#x22;The content is a health result with a value, range, status and meaning.&#x22;, instead: &#x22;result-card&#x22; },
  { case: &#x22;The content is an instruction the reader must act on.&#x22;, instead: &#x22;care-card&#x22; },
  { case: &#x22;The content is one measurement in a dashboard grid.&#x22;, instead: &#x22;metric-tile&#x22; },
  { case: &#x22;You need a layer over scrolling content that must stay readable.&#x22;, instead: &#x22;surface&#x22; },
  { case: &#x22;You want a border for advisory prose.&#x22;, instead: &#x22;callout&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="card"
  parts="[
  {
    name: &#x22;Card&#x22;,
    describes: &#x22;The root: a div, or an anchor when href is set. Carries the corner and print boundary.&#x22;,
    prop: &#x22;href&#x22;,
  },
  {
    name: &#x22;Card.Header&#x22;,
    describes: &#x22;Optional. An exported part the consumer fills.&#x22;,
  },
  {
    name: &#x22;Card.Title&#x22;,
    describes: &#x22;Holds the caller's heading element and resets its font and margin.&#x22;,
    prop: &#x22;title&#x22;,
  },
  {
    name: &#x22;Card.Description&#x22;,
    describes: &#x22;Optional line under the title. A paragraph, so inline content only.&#x22;,
    prop: &#x22;description&#x22;,
  },
  {
    name: &#x22;Card.Body&#x22;,
    describes: &#x22;Optional. The content, no contract of its own.&#x22;,
  },
  {
    name: &#x22;Card.Footer&#x22;,
    describes: &#x22;Optional. Actions or metadata in a wrapping row, split by the target minimum.&#x22;,
  },
]"
/>

<CompositionTree
  name="card"
  tree="[
  {
    part: &#x22;Card&#x22;,
    cardinality: &#x22;1&#x22;,
    children: [
      {
        part: &#x22;Surface&#x22;,
        cardinality: &#x22;1&#x22;,
        children: [
          {
            part: &#x22;Surface.Content&#x22;,
            cardinality: &#x22;1&#x22;,
            children: [
              {
                part: &#x22;card-link-row&#x22;,
                cardinality: &#x22;0..1&#x22;,
                children: [
                  {
                    part: &#x22;Card.Header&#x22;,
                    cardinality: &#x22;0..1&#x22;,
                    children: [
                      { part: &#x22;Card.Title&#x22;, cardinality: &#x22;1&#x22; },
                      { part: &#x22;Card.Description&#x22;, cardinality: &#x22;0..1&#x22; },
                      { part: &#x22;children&#x22;, cardinality: &#x22;0..n&#x22; },
                    ],
                  },
                  { part: &#x22;Card.Body&#x22;, cardinality: &#x22;0..1&#x22; },
                  { part: &#x22;Card.Footer&#x22;, cardinality: &#x22;0..1&#x22; },
                ],
              },
            ],
          },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### The whole card as one link [#the-whole-card-as-one-link]

`href` makes the card one anchor and one tab stop. A chevron marks it at rest,
the title underlines on hover, and it holds no buttons.

<ComponentPreview name="card-as-a-link" kind="example" align="start" />

### Cards on a sheet, and the rung that stays put [#cards-on-a-sheet-and-the-rung-that-stays-put]

The sheet is the layer; the cards on it stay on the `card` rung, since a
translucent rung never contains one.

<ComponentPreview name="card-on-a-sheet" kind="example" align="start" />

## Content guidelines [#content-guidelines]

A card holds one idea. If the title needs *and*, it is two cards. Titles are
noun phrases in sentence case. Card ships no copy, so every word inside is the
product's, held to [Plain-English A to Z](../content/plain-english-a-z.mdx).

<DoDont>
  <DoDont.Do>
    **"Recent readings"** as a card title, with the readings inside it.
  </DoDont.Do>

  <DoDont.Dont>
    **"Recent readings and upcoming appointments"** in one card. The boundary
    then tells the reader nothing.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

Audited against WCAG 2.2 AA across a source pass and a rendered pass. The audit
is author-run, not an independent review, and clinical review is still pending.

**Checked by `pnpm run check:a11y` on every commit.** Every colour is a role
token, no type size is in `px`, and no banned word appears.

**What the audit changed.** The link card's `focus-visible` ring used a
hardcoded 2px outline instead of the house tokens. It now reads
`--opsin-border-focus` and `--opsin-border-focus-offset`, so a product
raising those gets a wider ring here; the default stays 2px. The rendered
pass found no axe violation and no 320px reflow.

**What it left standing.** A link card is one anchor, so its accessible name is
all its text at once, a verbosity to weigh before putting `href` on a long
card. The resting chevron is `aria-hidden`, backed by the title underline and
anchor as redundant cues. A card otherwise takes no role or name
unless it is a link, carries the page's heading level in a title slot that resets
font and margin, floors a link target at `--opsin-target-minimum`, and keeps its
edge under greyscale, `forced-colors` and print.

<KeyboardTable
  name="card"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves to the card, when the card is a link&#x22;,
    notes: &#x22;One stop per card. A card without href is not focusable.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves backwards to the card&#x22;,
    notes: &#x22;Reverse order matches visual order.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Follows the link, on a card that has an href&#x22;,
    notes: &#x22;The root is a real anchor, so this is browser behaviour.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Scrolls the page&#x22;,
    notes: &#x22;A link activates on Enter, not Space.&#x22;,
  },
]"
/>

<ContrastReport component="card" />

## API reference [#api-reference]

<PropsTable name="CardProps" />

`rung` defaults to `card`, not `raised`, and never goes above `raised`: a
translucent rung warns in development and renders. `render` takes the
router's link element, not a function, and does nothing without `href`. There is
no `status`, `variant`, `color` or `category`.

<PropsTable name="CardHeaderProps" />

<PropsTable name="CardBodyProps" />

<PropsTable name="CardFooterProps" />

## Related [#related]

* [ResultCard](./result-card.mdx) is a Card with a clinical contract, any card holding a health value.
* [CareCard](./care-card.mdx) is a Card whose subject is an authored instruction.
* [MetricTile](./metric-tile.mdx) has a fixed grid contract, not a small Card.
* [Surface](./surface.mdx) is the layer beneath; Card is a Surface with padding and a boundary.
* [Callout](./callout.mdx) is bounded advisory prose, not grouped content.
