Skip to content

Composition

Matter's components share one composition spine. Every primitive picks the same three axes — variants, polymorphism, className — and exports its cva recipe so you can apply the same look to an element that isn't the primitive. Learn the spine once and the whole library reads the same.

The composition spine

Three orthogonal axes are present on (almost) every component.

  • cva variantsvariant, size, tone, shimmer. Discrete choices encoded as a class-variance-authority recipe. Exhaustive at the type level — every variant union is a TypeScript literal.
  • Slot polymorphismasChild (preferred) or as (legacy). Renders the recipe onto a child element of your choosing — a Next.js <Link>, a <span>, an <a>. The component keeps its variant + ref + accessibility wiring intact.
  • className passthrough — every component accepts className and merges it via cn() (clsx). Use it for one-off layout, not for visual overrides.

The three axes never collide. Variants change the recipe. asChild changes the element. className changes incidental layout. Pick the axis that matches your intent.

The variant factory pattern

Every variant-heavy component exports its cva factory. Same recipe, no wrapper required.

import { buttonVariants, pillVariants, displayHeadingVariants } from "@matter/components";

// Apply Button's recipe to a real <a>:
<a className={buttonVariants({ variant: "outline", size: "md" })} href="/start">
  Link CTA
</a>

// Apply Pill's recipe to a <span> inside running prose:
<span className={pillVariants({ tone: "green" })}>live</span>

// Apply DisplayHeading's recipe to a <p> used as a pull-quote:
<p className={displayHeadingVariants({ size: "card" })}>
  A company is a programmable object.
</p>

The recipes are tree-shakable strings. They carry zero runtime besides the cva resolver itself, and their VariantProps type gives you the same exhaustive variant union the parent component uses.

Export the cva factory next to the component. Document it on the component page. External code reuses the recipe without wrapping.
Don't fork a recipe by re-implementing the class string. The factory IS the source of truth — fork it and the next token change drifts.

The asChild pattern

asChild is the preferred polymorphism axis. It uses Radix's Slot to merge the parent's classes, ref, and event handlers onto the only child element — no wrapper div, no inherited semantics from the original tag.

import { Button } from "@matter/components";
import Link from "next/link";

<Button asChild variant="dark" size="md">
  <Link href="/start">Start an entity</Link>
</Button>

The rendered DOM is a single <a> carrying every .btn class. The <Link> keeps its routing behaviour; the <Button> keeps its variant grammar.

When to use asChild:

  • The action is navigation (<Link>, <a>).
  • The element has its own semantics that matter (<label>, <summary>).
  • A Radix primitive demands a controlled trigger and you want a Matter-styled one — every Radix Trigger already accepts asChild.

When NOT to use asChild:

  • The child is more than one element. Slot composes onto exactly one node — wrap multiple children in a fragment and Slot will throw at runtime.
  • The child doesn't accept className and ref. Slot needs both to merge the recipe and forward focus.
  • You want a real <button> with its native semantics. Just render <Button> directly.

How Slot works, briefly. Radix's Slot doesn't render its own element. At commit time it walks one level into its children, finds the single React element, and clones it with the parent's className, ref, and event handlers merged in. The child's own props win on conflict — your <Link href="…"> keeps its href even though Slot is forwarding through. The result is one node in the DOM with both surfaces' contributions on it.

<Button asChild><Link href="/start">Start</Link></Button> — Slot merges the recipe onto the only child element.
Don't wrap a <Link> in a <Button> without asChild. You'll get a <button> containing an <a> — invalid HTML and a double-tab-stop.

Compound components

Some Matter surfaces are inherently multi-part. Those primitives expose their parts on a namespaced object so the structure is visible at the callsite.

PrimitiveCompound APIUsage
TimelineTimeline.Search, Timeline.KindToggleRow, Timeline.KindChip, Timeline.ActorChip, Timeline.SourceChip, Timeline.KindDotLifecycle event surfaces. The namespace gathers the filter row + per-row chips + dot.
CK (CardsKit)CK.Card, plus every primitive registered through defineCardPrimitiveDynamic-card framework. Every Claude-rendered card composes through CK. See Dynamic cards.
IconEvery Lucide + custom SVG export under a single namespace<Icon.ArrowRight />, <Icon.Check />. The namespace keeps every icon import one line.

Most Matter primitives are not compound. Card, Glass, Button, Pill, DisplayHeading, Stat, Eyebrow are thin wrappers — they carry their own padding + recipe and accept arbitrary children. The implicit structure rule is: if the surface has more than two named parts, expose them on a namespace; otherwise let children carry the composition.

Future direction — a compound API for Card (Card.Header, Card.Body, Card.Footer) is tracked but not yet shipped. For now, compose card sections by hand: an Eyebrow, a DisplayHeading, the body, an Action-row footer.

The className contract

Every component merges className last. The contract is the same everywhere:

<div className={cn(componentVariants({ variant, size }), className)}>

What this means at the callsite:

  • Use className for incidental layout. Margin, max-width, grid placement, one-off positioning that doesn't belong in the recipe.
  • Use variants for the visual change. Size, tone, shimmer, density — the things that come back on other surfaces. If you find yourself reaching for className to change a colour, the variant is missing — add it to the cva factory.
  • Never stack to override. A className that re-declares padding to defeat the recipe's padding is drift — the next variant pass will fight you.
<Button variant="outline" size="lg" className="mt-6" /> — variant carries the look, className carries the layout offset.
Don't write <Button className="bg-stone-900 text-white" /> to fake the dark variant. Use variant="dark".

Polymorphism — asChild vs as

Two polymorphism patterns coexist for back-compat. New code should reach for asChild.

asChild (Radix Slot, preferred):

  • Works at runtime by cloning the child and merging props.
  • TypeScript inference flows from the child element — href is required on <Link>, htmlFor on <label>.
  • Composes cleanly with any Radix Trigger slot.
  • Wins when both asChild and as are set.

as (legacy ElementType prop, kept for back-compat):

  • Renders the chosen tag with the recipe applied — <Button as="a" href="/start" />.
  • The child-element prop surface isn't typed from the tag. href is any rather than required.
  • Doesn't compose with Radix triggers (Radix demands asChild).
  • New primitives should not add an as prop. Existing primitives keep theirs for migration runway.
<Button asChild variant="dark"><Link href="/start">Start</Link></Button> — Slot merges the recipe, Link keeps its routing, href is required by Link's types.
Don't reach for <Button as="a" href="/start" /> in new code. The href isn't type-checked and you can't drop a Radix trigger inside.

Worked example — composing three primitives

A featured CTA card. Glass surface, display heading, button-as-link. No new component required — every piece exists in @matter/components.

import { Glass, DisplayHeading, Button } from "@matter/components";
import Link from "next/link";

<Glass tone="light" className="max-w-md">
  {/* Glass = base surface. tone="light" pulls --glass-light; tone="dark" inverts. */}

  <DisplayHeading size="card" as="h3">
    A company is a programmable object.
  </DisplayHeading>
  {/* size="card" picks the .m-h2 recipe. `as` overrides the default <h2>
      so the document outline stays correct inside this section. */}

  <p className="m-body" style={{ marginTop: "var(--space-3)" }}>
    Create, manage, and exit entities through one API. No forms, no PDFs.
  </p>

  <Button asChild variant="dark" size="md" className="mt-6">
    <Link href="/start">Start an entity</Link>
  </Button>
  {/* asChild merges .btn onto <Link>. The rendered DOM is one <a> —
      no wrapper button, no double tab-stop, href type-checked. */}
</Glass>

Every axis from the spine is in play. Variants set the look (tone, size, variant). asChild swaps the element under the recipe. className carries the incidental layout (max-w-md, mt-6). No custom CSS, no recipe fork.

The same composition would fail in three ways if the spine were broken:

  • Without asChild, the rendered DOM nests <a> inside <button> — invalid HTML, double tab-stop, screen readers read both.
  • Without the variant factory pattern, the recipe would be a copy-pasted class string, drifting the next time --btn-radius-md moves.
  • Without the className contract, the layout offsets would be smuggled into a style prop, fighting the recipe on every theme change.

Read the spine once. Then every page in this catalogue reads the same way — find the variant axis, decide whether asChild applies, layer one className for layout, ship.

Summary

AxisUse forExample
cva variantsDiscrete recurring choices — size, variant, tone<Button variant="outline" size="lg" />
Variant factoryApply the recipe to a non-component element<a className={buttonVariants({ variant: "outline" })} />
asChildRender the recipe on a different element with its own semantics<Button asChild><Link href="…">…</Link></Button>
classNameOne-off layout — margin, max-width, grid placement<Glass className="max-w-md" />
Compound namespaceMulti-part primitives with more than two named parts<Timeline.KindChip kind={…} />

On this page