Team plan
Unlimited projects, priority support and advanced analytics for growing teams.
Billed annually.
A bounded surface for one thing — a record, a summary, a choice — with an optional heading row and action row, padded and spaced by the theme's rhythm.
Install the package:
pnpm add @design-schema/react @design-schema/tokens Import the stylesheet once for the app, then the component:
import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { Card } from '@design-schema/react'; Unlimited projects, priority support and advanced analytics for growing teams.
Billed annually.
import { Card, Stack, Text, Button } from '@design-schema/react';
<Card
heading="Team plan"
footer={<>
<Button label="Choose plan" variant="primary" size="sm" />
<Button label="Compare plans" variant="secondary" size="sm" />
</>}
>
<Stack gap="tight">
<Text element="p">Unlimited projects, priority support and advanced analytics for growing teams.</Text>
<Text element="p" tone="muted" size="sm">
Billed annually.
</Text>
</Stack>
</Card>| Prop | Type | Default | Description |
|---|---|---|---|
| children required | content | — | The body. Usually a Stack of Text and controls; a plain string or number is rendered inside the system Text with its defaults (a bare string cannot sit in a native View), including each top-level string or number in an array body. The wrap is a native requirement, not a cross-platform contract: Lit leaves a bare slotted text node alone, because wrapping it would mean reparenting the consumer's light-DOM children, which Card never does. Fragments and arrays are flattened before the wrap, exactly as they are when the interactive target is looked for, so a string directly inside a top-level Fragment is wrapped too. |
| heading | string | — | The card's title, rendered as the system Heading at the card's level and at `size: lg` on every platform, so a card heading reads smaller than a page heading. Omit for cards that are a single piece of content; an empty string counts as omitted (no article, no label) — but the header row still renders when `headerActions` is set, holding the actions alone. It is an anatomy part by name only: it carries no `data-part`, since it is the system Heading and keeps that component's own hook. |
| headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | Heading level for `heading`, so cards fit the page outline. Cards in a list share a level. |
| headerActions | content | — | Controls at the end of the header row — a ghost icon-only Button, a Link. At most two: a content guideline, not a runtime check, as with every other soft content limit here. |
| footer | content | — | The action row. Buttons in a row, primary first, following Form's action-order rule. |
| inset | sm | md | lg | md | Padding inside the card from the layout inset presets. `sm` for dense grids, `lg` for a single featured card. |
| surface | default | subtle | default | `default` is the page background with a border — the calm option; `subtle` is a tinted surface without a border. |
| interactive | boolean | false | The whole card is one link or button target. Requires exactly one interactive child (a Link or Button) whose action the card extends to its full area; the card itself is not focusable. The child is looked for among the top-level children of the body only (web and Lit also accept a native a[href] or button there); controls nested inside a wrapper such as a Stack are not searched, so place the link at the top level beside any Text. A top-level Fragment is flattened, so its children count as top-level; on Lit there is nothing to flatten, since a nested template's nodes are already siblings and are assigned to the default slot as top-level children. With zero or several such children the card stays non-interactive (no hit area, no hover background, no press) and warns once per mounted card in development — latched for the life of the mount, so a card that goes valid and then invalid again does not warn a second time. The warning text is a development diagnostic, not user-facing copy, so it is not a `copy.*` string and its wording may differ between platforms. If the child is disabled the card is disabled with it: no hover background, pressing does nothing, and on native the Pressable reports disabled. The card itself never dims — there is no disabled opacity binding here; the child renders its own disabled state. Controls in `headerActions` and `footer` are never the target; they sit above the hit area and keep their own targets. Accessibility: The card never becomes a second focus stop; its single child link or button is the target, and the card enlarges the hit area only (pseudo-element on web, wrapping Pressable on native). |
| focusable | boolean | false | The card root takes tabindex=-1 so a container (Feed) can move focus to it by script, and draws its own focus ring when focused that way. Not a tab stop; not for making cards clickable (`interactive`). With `interactive` also set, `interactive` wins and this is a no-op — the card already has a target — and a development warning says so. It stays a no-op whenever `interactive` is set, even when that card fell back to non-interactive for want of a single target. On web and Lit the ring is an outline of focusRingWidth in focusRing, drawn outside the box, so a focusable card reserves no border; React Native has no outline, so there it reserves border.width.focus like an interactive card. On Lit the shadow root deliberately does not use delegatesFocus — the documented exemption from the rule that focusable elements delegate — so scripted focus lands on the card and not on its first focusable child. Accessibility: Only scripted focus (PageUp/PageDown in a Feed) lands here, so the card draws the ring whenever it takes focus, not only on :focus-visible: it sets a focus-ring state in its own focusin handler and clears it on focusout, skipping the state when that focusin follows a pointerdown on the card. Plain :focus-visible is not enough — Chromium does not match it on a programmatic focus() that follows a pointer interaction, which is exactly the Feed case. |
Decisions this component leaves to you
Choose the heading level so it follows the heading above it on your page. The component will not guess the outline it lands in.
No implicit ARIA role — the component is styling and structure, and adds nothing to the accessibility tree.
Anatomy
What generation guarantees
Heading hierarchy — Headings this component renders take their level from the caller, so the page outline never skips a level.
Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.
Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.
This component does not animate, so it has no reduced-motion behaviour to describe.