Skip to content
ComponentsDynamicCard

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.

Do — pass the agent's raw spec directly to spec. The renderer validates it; you don't need to pre-parse.
Don't — render a card spec without the framework. Bypass leaves you re-implementing the parse-fail / partial / error-boundary contract.

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 identifierre-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 type in 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

DynamicCard takes no documented props.

Streaming and partial state

DynamicCard's resilience contract was designed for streaming agent output. Each transition:

StateTriggerVisual
partial: true + parse OKStreaming in, spec parses as-isRenders the valid tree; primitives may be incomplete
partial: true + parse failStreaming in, spec mid-write<CardSkeleton> — neutral placeholder
partial: false + parse OKStream completeFull render
partial: false + parse failStream complete, malformed spec<CardFallback reason="parse_failed">
Unknown primitiveValid spec, type not in registryInline <CardFallback> for that subtree
Render throwPrimitive crashesErrorBoundary 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 through useCardAction().
  • ARIA roles and properties. Root wrapper has no role override. CardSkeleton sets aria-busy="true". CardFallback sets role="status" with aria-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 on CardFallback (async-update rule). Color is never the sole differentiator — every primitive uses both color + text/icon.

Do / Don't

Do — import @matter/components/cards once at app boot. Primitive registration is a side-effecting import.
Don't — import primitives ad-hoc. The registry build is order-sensitive at first load; importing one primitive without the barrel can produce missing-primitive fallbacks elsewhere.
Do — pass 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.
Don't — pre-validate the spec on the consumer side. The renderer validates; double-validating just doubles the work and adds a place for drift.
Do — wire onAction to your action sink (Redux, Zustand, a server-action) so primitive-level Button clicks propagate cleanly.
Don't — register custom primitives unless you've genuinely got a missing surface. The 24 built-ins cover most agent-output shapes; new primitives need governance approval.

Recipes

This component appears in:

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);
        }
      }}
    />
  );
}

Source

packages/components/src/cards/renderer

On this page