DynamicCard
Hero example
When to use
Reach for <DynamicCard> whenever an agent emits a JSON spec that should render as a Matter UI card — chat-bot replies, AI-augmented dashboard panels, streaming tool-call results, the assistant's "here's what I built" moments. The renderer parses the spec defensively, falls back gracefully on bad input, and composes from a registry of pre-registered primitives so the rendered card always looks Matter-native.
The Dynamic Cards system is the canonical agent-output surface. When you're building a new agent that emits structured UI, your output schema is CardZ and your renderer is <DynamicCard> — never raw HTML, never custom rendering. The registry's invariants (typed children, validated actions, error-boundary'd primitives) prevent agent-generated UI from breaking the surrounding app.
Don't reach for DynamicCard for static, hand-authored cards — those are the InlineCard family (BoardConsentCard, FilingStatusCard, OptionGrantCard, etc.). Don't use it as a templating engine either; the registry is closed to consumer-defined types unless you register them at app boot.
spec. The renderer validates it; you don't need to pre-parse.Anatomy
The Dynamic Cards framework has six layers. Each gets a paragraph of detail; the props table below covers DynamicCard's own surface.
1. The spec schema (CardZ)
Every card is a JSON object validated against the CardZ Zod schema. The shape:
type Card = {
id: string; // stable identifier — re-renders keyed off this
version: 1; // schema version; renderer rejects unknown versions
meta?: CardMeta; // optional metadata (kind, source, trace ID)
root: CardNode; // the root primitive
};
type CardNode = {
type: string; // primitive type, must be in the registry
children?: CardNode[];
[key: string]: unknown; // primitive-specific props
};CardZ is exported from @matter/components — agents emitting cards should import it and validate before sending. The renderer validates again; double-checking catches schema drift early.
2. The action schema (CardActionZ)
Buttons inside a card emit CardAction objects when activated. The shape includes kind, id, and a flexible payload. Validated via CardActionZ; invalid actions are ignored (never propagated).
3. The meta schema (CardMetaZ)
Top-level metadata: which agent emitted the card, which conversation it belongs to, what tracing ID propagates through the system. Optional but recommended.
4. The primitive registry
Twenty-four primitives ship out of the box: Card, Section, Row, Stack, Stat, Pill, Progress, IconTile, Kicker, KVGrid, KVRow, StepList, Person, Avatar, DocChip, Eyebrow, Heading, Subtitle, Body, Mono, Button, ButtonGroup, Hairline, Spacer, Icon. Each is registered at module load via defineCardPrimitive. Importing @matter/components/cards is a side-effecting barrel — do it once at app boot.
Consumers can register custom primitives via defineCardPrimitive(name, def) — but the bar is high: any custom primitive must pass the same a11y + forced-colors + reduced-motion contract as built-ins. Most consumers should ask whether their need fits an existing primitive first.
5. The renderer (<DynamicCard>)
The component on this page. Memoised; re-renders only when the spec reference changes. Internally:
- Validates spec via
CardZ.safeParse. - On parse failure with
partial: true→ renders<CardSkeleton>. - On parse failure with
partial: false→ renders<CardFallback reason="parse_failed">. - On valid parse → walks the tree, looks up each
typein the registry, renders. - Unknown primitive type → renders
<CardFallback>inline (doesn't crash the tree). - Render throw → caught by
RenderBoundary, swapped for<CardFallback>.
6. The action context (CardActionProvider)
Wraps the rendered tree so Button primitives can useCardAction() to emit actions back to the consumer. The onAction prop on DynamicCard receives every validated action.
Props
Streaming and partial state
DynamicCard's resilience contract was designed for streaming agent output. Each transition:
| State | Trigger | Visual |
|---|---|---|
partial: true + parse OK | Streaming in, spec parses as-is | Renders the valid tree; primitives may be incomplete |
partial: true + parse fail | Streaming in, spec mid-write | <CardSkeleton> — neutral placeholder |
partial: false + parse OK | Stream complete | Full render |
partial: false + parse fail | Stream complete, malformed spec | <CardFallback reason="parse_failed"> |
| Unknown primitive | Valid spec, type not in registry | Inline <CardFallback> for that subtree |
| Render throw | Primitive crashes | ErrorBoundary swaps for <CardFallback> |
This means an agent that streams its card spec character-by-character can pipe each partial directly through DynamicCard without doing any pre-processing on the consumer side. The skeleton shows during the parse-miss window, then the card resolves cleanly.
Telemetry
The onParseEvent prop fires for every parse outcome:
onParseEvent?: (event: DynamicCardParseEvent) => void;
type DynamicCardParseEvent =
| { kind: "ok"; card_id: string }
| { kind: "parse_failed"; issues: unknown }
| { kind: "unsupported_version"; card_id?: string; version?: unknown };Wire this into PostHog (or your telemetry sink) to track agent-output quality across consumers — high parse-failure rates against a particular agent or model are a signal the agent's emitter is drifting from the schema.
Themes
Tokens consumed
Every primitive in the registry consumes its own token surface. The DynamicCard wrapper itself reads no tokens — it's a pass-through. The rendered tree inherits the surrounding context's --theme and --density attributes.
Accessibility
- Keyboard interactions. Tab order follows the rendered primitive tree. Button primitives carry standard
<button>semantics — Enter / Space activate; the action propagates throughuseCardAction(). - ARIA roles and properties. Root wrapper has no role override.
CardSkeletonsetsaria-busy="true".CardFallbacksetsrole="status"witharia-live="polite"so screen readers announce when a parse failure happens. - Focus order. Each primitive owns its own focus contract. Card-level wrappers are non-focusable.
- Screen-reader expectations. Each primitive announces per its individual a11y contract. The card root announces no prefix — readers traverse the tree in DOM order.
- Reduced motion. Transitions between skeleton → resolved card collapse to opacity under
prefers-reduced-motion: reduce. - Forced colors. Each primitive collapses to system colors. The card root provides no fallback color of its own.
- WIG rules. Real
<button>elements via the Button primitive (semantic-element rule). Live region onCardFallback(async-update rule). Color is never the sole differentiator — every primitive uses both color + text/icon.
Do / Don't
@matter/components/cards once at app boot. Primitive registration is a side-effecting import.partial: true while the spec is still streaming in. Skeleton on parse-miss is the right UX; fallback on parse-miss is for terminal failures.onAction to your action sink (Redux, Zustand, a server-action) so primitive-level Button clicks propagate cleanly.Recipes
This component appears in:
- Docs corpus recipe — agent replies that render structured card output.
- Board recipe — pending consents are rendered as DynamicCards.
- Equity recipe — grant-proposal cards in the agent thread.
Code example
import { DynamicCard } from "@matter/components/cards";
// Important: importing the barrel above registers every built-in primitive.
export function AgentReplyCard({ spec, streaming }: { spec: unknown; streaming: boolean }) {
return (
<DynamicCard
spec={spec}
partial={streaming}
onAction={(action) => dispatchAction(action)}
onParseEvent={(event) => {
if (event.kind === "parse_failed") {
logger.warn("Agent emitted invalid card spec", event.issues);
}
}}
/>
);
}