A lifted surface for one section of an app, with a header, a body and nesting.
Header and body
The header holds a title and a note, ruled off from the body. The title is the card's heading. Long words break anywhere in a card; set overflow-wrap: normal on an element that must not break, such as a code sample.
Example
Example
Notifications
Where we reach you, and how often.
Product updates, when something ships.
A weekly digest, every Tuesday morning.
import { Button, Card, CardBody, CardHeader, CardNote, CardTitle } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
/**
* The buttons end the card's body, inside its bottom end curve, so the row keeps the card's corner inset under
* it and the buttons keep their own shape and their place on the column's end edge: 7px more in Rounded,
* nothing more in Soft and Square.
*/
const CLEAR_OF_CORNER = 'calc(var(--holder-corner-inset) - var(--holder-pad))';
export default function CardParts(): ReactElement {
return (
<Card as="section" aria-labelledby="card-parts-title" style={{ maxWidth: 480, marginInline: 'auto' }}>
<CardHeader>
<CardTitle id="card-parts-title" level={3}>
Notifications
</CardTitle>
<CardNote>Where we reach you, and how often.</CardNote>
</CardHeader>
<CardBody>
<p>Product updates, when something ships.</p>
<p>A weekly digest, every Tuesday morning.</p>
<div className="row" style={{ justifyContent: 'flex-end', marginBlockEnd: CLEAR_OF_CORNER }}>
<Button variant="tertiary">Discard</Button>
<Button>Save changes</Button>
</div>
</CardBody>
</Card>
);
}Actions in the header
Put a card's own actions in CardActions after the title and the note. Show at most two: for three or more, show the first and put the rest in a More menu, as an account's Details card shows Edit and keeps Merge and Remove in the menu. Beside the title they end its line, level with its first line however far the title wraps, and move down by the card's corner inset, so they keep their own shape and stand clear of the rounded corner, on the body's column edge: 7px in Rounded, nothing in Soft and Square. Where two buttons do not fit beside the title's 12rem they wrap among themselves at the end, and the note still follows the title. In a header narrower than 24rem nothing moves: the title and the note sit where they would without actions, and the actions take a row of their own after the note, at the start, so the order on screen is the order a screen reader reads. A label too long for the card wraps inside its button.
The header takes no width from its actions, so a card on a phone never scrolls the page sideways. A card that holds only a header and sits where it takes the width of its content, such as in a flex row, needs a width of its own.
Example
Example
Account details
Updated from your bank feed
Everyday Visa, a credit card with Northwind Bank.
'use client';
import {
Button,
Card,
CardActions,
CardBody,
CardHeader,
CardNote,
CardTitle,
ConfirmDialog,
Dialog,
DialogTrigger,
IconButton,
Menu,
MenuItem,
MenuTrigger,
} from '@snapshot-ui/react';
import { Ellipsis } from 'lucide-react';
import { useState, type ReactElement } from 'react';
/**
* An account's Details card with Edit and a More menu in its header: at most two visible actions, the rest in
* the menu. Edit opens its Dialog from a DialogTrigger; Merge and Remove open theirs from the menu, and every
* one gives focus back to the button that opened it. The actions end the title's line and stand clear of the
* card's rounded corner; in a header under 24rem they follow the note.
*/
export default function CardActionsExample(): ReactElement {
const [open, setOpen] = useState<'merge' | 'remove' | null>(null);
const close = (isOpen: boolean): void => {
if (!isOpen) setOpen(null);
};
return (
<Card
as="section"
aria-labelledby="card-actions-title"
style={{ width: '100%', maxWidth: 560, marginInline: 'auto' }}
>
<CardHeader>
<CardTitle id="card-actions-title" level={3}>
Account details
</CardTitle>
<CardNote>Updated from your bank feed</CardNote>
<CardActions>
<DialogTrigger>
<Button variant="secondary" aria-label="Edit details">
Edit
</Button>
<Dialog
title="Edit details"
description="The name and type the account shows everywhere."
actions={({ close: done }) => (
<>
<Button variant="secondary" onPress={done}>
Cancel
</Button>
<Button onPress={done}>Save details</Button>
</>
)}
/>
</DialogTrigger>
<MenuTrigger>
<IconButton aria-label="More actions" variant="outline">
<Ellipsis />
</IconButton>
<Menu
onAction={(key) => {
setOpen(key === 'merge' ? 'merge' : 'remove');
}}
>
<MenuItem id="merge">Merge</MenuItem>
<MenuItem id="remove" variant="danger">
Remove
</MenuItem>
</Menu>
</MenuTrigger>
</CardActions>
</CardHeader>
<CardBody>
<p>Everyday Visa, a credit card with Northwind Bank.</p>
</CardBody>
<Dialog
isOpen={open === 'merge'}
onOpenChange={close}
title="Merge Everyday Visa"
description="Its transactions move into the account you choose, and this one closes."
actions={({ close: done }) => (
<>
<Button variant="secondary" onPress={done}>
Cancel
</Button>
<Button onPress={done}>Merge accounts</Button>
</>
)}
/>
<ConfirmDialog
isOpen={open === 'remove'}
onOpenChange={close}
tone="danger"
title="Remove Everyday Visa?"
description="Its transactions and its history leave every report. This cannot be undone."
confirmLabel="Remove Everyday Visa"
/>
</Card>
);
}A description list in a card
An account's details are a description list as the card's body, with the card's Edit in its header. The list keeps a row's controls clear of the bottom corner, as CardActions keeps the header's clear of the top one.
Example
Example
Account information
- Name
- Everyday Visa
- Type
- Credit card
- Institution
- Northwind Bank
- Last four
- 4821
- Bank feed
- Connected
- Nickname
- Add a nickname
- APR
- 24.9%
import {
Badge,
Button,
Card,
CardActions,
CardBody,
CardHeader,
CardTitle,
DescriptionList,
DescriptionListItem,
Link,
} from '@snapshot-ui/react';
import type { ReactElement } from 'react';
/**
* The list is the card's body. Its last row's Edit ends the card, inside its bottom end curve, so the row takes
* the card's corner inset under it and Edit keeps its place on the column's end edge: 7px more in Rounded,
* nothing more in Soft and Square, and nothing while the list is stacked, where Edit is on the term's line. The
* list does this itself. The header's Edit, a secondary Button in CardActions, stands clear of the top end curve
* the same way: beside the title it moves down by the corner inset less the card's padding. An app opens its
* form from it, as the card page's Actions example does.
*/
export default function DescriptionListCard(): ReactElement {
return (
<Card
as="section"
aria-labelledby="description-list-card-title"
style={{ width: '100%', maxWidth: 560, marginInline: 'auto' }}
>
<CardHeader>
<CardTitle id="description-list-card-title" level={3}>
Account information
</CardTitle>
<CardActions>
<Button variant="secondary" aria-label="Edit details">
Edit
</Button>
</CardActions>
</CardHeader>
<CardBody>
<DescriptionList>
<DescriptionListItem term="Name">Everyday Visa</DescriptionListItem>
<DescriptionListItem term="Type">Credit card</DescriptionListItem>
<DescriptionListItem term="Institution">Northwind Bank</DescriptionListItem>
<DescriptionListItem term="Last four" isNumeric>
4821
</DescriptionListItem>
<DescriptionListItem term="Bank feed">
<Badge>Connected</Badge>
</DescriptionListItem>
{/* A missing value: a link to add it, in place of the value, and no action beside it. */}
<DescriptionListItem term="Nickname" emptyValue={<Link href="#nickname">Add a nickname</Link>} />
<DescriptionListItem term="APR" isNumeric>
24.9%
</DescriptionListItem>
<DescriptionListItem
term="Credit limit"
isNumeric
actions={
<Link href="#credit-limit" aria-label="Edit Credit limit">
Edit
</Link>
}
>
$5,000.00
</DescriptionListItem>
</DescriptionList>
</CardBody>
</Card>
);
}Nesting
A card inside another card, directly or in its body, takes a corner concentric with the outer card's, its radius less the outer card's edge and padding (7px in Rounded), and takes an outline. A card on a card has no tone step to set it apart. A control of your own in a card's corner, such as a button that ends its body, keeps its own shape and moves out of the corner instead: keep the card's --holder-corner-inset under it, so it stays on the column's edge, as the row under Header and body does with margin-block-end: calc(var(--holder-corner-inset) - var(--holder-pad)).
Example
Example
Workspace
Shown in the header and in every invite.
Northwind Studio
Danger zone
Deleting a workspace cannot be undone.
Every member loses access at once.
import { Card, CardBody, CardHeader, CardNote, CardTitle } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function CardNested(): ReactElement {
return (
<Card as="section" aria-labelledby="card-nested-title" style={{ maxWidth: 480, marginInline: 'auto' }}>
<CardHeader>
<CardTitle id="card-nested-title" level={3}>
Workspace
</CardTitle>
<CardNote>Shown in the header and in every invite.</CardNote>
</CardHeader>
<CardBody>
<p>Northwind Studio</p>
<Card as="section" aria-labelledby="card-nested-danger">
<CardHeader>
<CardTitle id="card-nested-danger" level={4}>
Danger zone
</CardTitle>
<CardNote>Deleting a workspace cannot be undone.</CardNote>
</CardHeader>
<CardBody>
<p>Every member loses access at once.</p>
</CardBody>
</Card>
</CardBody>
</Card>
);
}Usage
Card is static. It renders on the server and ships no JavaScript, and so does CardActions: the buttons you put in it are yours. For a card that folds, use Collapsible card, whose actions prop draws the same way. Choose the element with as and the heading level with level, so the card fits the page's outline.
import { Button, Card, CardActions, CardBody, CardHeader, CardNote, CardTitle } from '@snapshot-ui/react';
export function Notifications() {
return (
<Card as="section" aria-labelledby="notifications-title">
<CardHeader>
<CardTitle id="notifications-title" level={2}>
Notifications
</CardTitle>
<CardNote>Where we reach you, and how often.</CardNote>
<CardActions>
<Button variant="secondary" aria-label="Edit notifications">
Edit
</Button>
</CardActions>
</CardHeader>
<CardBody>...</CardBody>
</Card>
);
}Props
Every part takes className (merged after its own class), ref and its other HTML attributes. These are the props that choose an element.
Card
as "article" | "div" | "section" | The element to render. A Default: 'div' |
|---|---|
className string |
CardTitle
className string | |
|---|---|
level 1 | 2 | 3 | 4 | The heading element, Default: 2 |
CardActions
A div with no props of its own; its children are your buttons, links or a menu trigger.
children React.ReactNode | |
|---|---|
className string |
Accessibility
A card is a div unless as says otherwise. A section named by aria-labelledby or aria-label is a region landmark; without a name it is not announced. An article is never a landmark.
The header is a div, not a header element. A header outside sectioning content is the page's banner, so two cards at page level would add two banners. The title renders the heading level you choose and looks the same at every level.
A card is not interactive. To make one lead somewhere, put a link in its title. Never nest buttons or links in a clickable card.
CardActions adds no role and comes after the title and the note, so a screen reader and the Tab key meet the actions after the words they act on. Name each button with what it acts on, "Edit details" rather than "Edit", since a page of cards holds many Edits. A button that opens a dialog gets focus back when the dialog closes.
Design rules
What keeps cards in the system.
One title per card, and make it the heading that names the card.
Nest a card only for something that stands apart, such as a danger zone.
Let the body space its children at 24px and leave their margins alone.
Wrap text in a paragraph. The body is a grid, so bare text beside an element takes a row of its own.
Put the card's own actions in CardActions, after the title and the note.
Show at most two actions in a header. For three or more, show the first and put the rest in a More menu.
Never trim the 32px padding to fit more in. It is what separates a card from its content.
Never use a card for a single control or a short label. A card holds a section.
Never place a button in the header's corner by hand. CardActions keeps it clear of the curve.