← README · Typography · The essay shell · Build-time tooling · The CLI
Content blocks from @supertype.ai/foundations/blocks, plus the MDX map that makes
them available in markdown.
TabGroup is a client component (Base UI); everything else renders on the
server.
Two shapes, one component. Pass title and description for the common case, or
compose the slots yourself when you need more.
{
/* Shorthand — what MDX authors write */
}
<Card
href="/notes/streaming-pipelines"
title="Build a streaming pipeline"
description="Kafka in, warehouse out, four parts."
icon={<DatabaseIcon />}
/>;
{
/* Composed */
}
<Card>
<CardHeader>
<CardTitle>Usage</CardTitle>
<CardDescription>Billing period to date</CardDescription>
</CardHeader>
<CardContent>
<TypographyStat size="panel">18,204</TypographyStat>
</CardContent>
</Card>;
{
/* A grid of them */
}
<Cards>
<Card title="One" href="/one" />
<Card title="Two" href="/two" />
</Cards>;Where the href goes is resolveLink's call, not the call site's. The
exported slots are
CardHeader, CardTitle, CardDescription and CardContent, and Cards is a
two-column grid from sm up.
CardTitle does not use the heading face. A card is chrome rather than prose, so
on an .editorial surface it stays in the body sans instead of turning serif and
losing the weight that separates it from the description.
<Button tone="destructive" variant="ghost">Delete</Button>
<Callout tone="warn" title="Rotate the key first">…</Callout>
<TypographyLink href="/pricing" tone="primary">See pricing</TypographyLink>| tone | means | fill / ink / hue |
|---|---|---|
muted |
nothing; chrome | --muted / --foreground / --foreground, hairline --border |
primary |
the principal action | --primary / --primary-foreground / --primary-ink |
secondary |
the warm accent | --secondary / --secondary-foreground / --secondary-ink |
brand |
the app's own identity | --brand / --brand-foreground / --brand-ink |
success |
it worked | --success / --success-foreground / --success-ink |
warn |
a footgun | --warn / --warn-foreground / --warn-ink |
destructive |
it deletes, or it failed | --destructive / --destructive-foreground / --destructive |
Seven tones, seven tokens, one to one. That is the bar for admitting a new one, and three names failed it:
neutral— no such token, andmutedis already the package's word for the quiet register (--muted-foreground,TypographyMuted,TypographyP tone="muted").accent—--accentis--primary's hover tint, sotone="accent"and a washedtone="primary"rendered the same control.info— a real token, butsuccess | warn | destructiveis already the whole good/careful/bad triad, and neither app had ever reached for a fourth.
One list: Callout used to have muted, TypographyLink used to have
foreground, and a button's default was the same idea a third time.
muted's hue is --foreground, not --muted-foreground. The tone says the
control carries no meaning, not that it carries less contrast — a Cancel button
beside a Save button is quiet when it is unfilled, and its label still has to be
read.
Three values per row, not one: a fill is a mark that clears 3:1, and an ink is
read and clears 4.5:1. --tone-fill is the surface, --tone-ink is the label
printed on it, and --tone-hue is the same colour as words. /contrast's
checkSignals is the test.
A component spends what a tone declares — --tone-line for a hairline,
--tone-veil for a panel's tint, --tone-wash for a control's, --tone-hue for
ink — so neither Button nor Callout contains a single per-tone branch. Adding
an eighth tone is one row in tone.ts and nothing anywhere else.
Painting your own surface with one is toneClass(tone), which returns the whole
class list. It used to be two exports combined by hand, and the order was
load-bearing in a way nothing enforced: the derived values have to come first so
muted can override --tone-line with --border. Two arguments whose order
matters and whose values always travel together is one argument.
toneClass is a palette, not a surface. A Callout spends the same seven values
a filled Button does and tints at 5%, so the words inside it still sit on the
page and still want the page's ink.
So the ink is handed down by whatever actually paints. Fill a surface with a tone
and add INK_ON_FILL; paint any other, --card, --popover, --sidebar or one
of your own, and spread inkOnSurfaceStyle(token) into style. It is properties
rather than a class because Tailwind generates only the classes it can read as
text in the package, so a class assembled at runtime resolves to nothing at all.
Card and Bulletin already do it for the card. Both set --ink and
--ink-muted, which every type primitive reads with the page as its fallback:
<div className={cn(toneClass("brand"), INK_ON_FILL, "bg-(--tone-fill) p-4")}>
<TypographyLabel>Reads the surface, not the page</TypographyLabel>
</div>Skip it and a nested TypographyLabel prints --foreground on your fill.
That shipped: a hand-rolled anchor styled bg-primary text-primary-foreground
with a TypographyLabel inside measured 2.34:1. The preset's own ink had
won over the colour it inherited. test/composition.test.ts measures every
surface against every primitive in both themes, which is what makes the token
pair check sufficient.
On a hue fill --ink-muted collapses to --ink: mixing a label 20% toward its
fill measures 4.22:1 on the dark theme. Nothing on a filled control may be
quieter than its label — two rungs means you wanted a tinted surface.
brand is the one row the package does not define. Each cut falls back to the
matching cut of primary, so an app with no identity hue of its own gets its
principal one rather than an invisible control.
Declare one cut and you owe the rest. The fallbacks are per property, so an
app that sets --brand and stops gets its own fill with the package's label on
it — a bronze --brand took white from --primary-foreground and printed every
solid brand badge at 2.80:1. Nothing renders wrong enough to notice, which is why
checkSignals now resolves each cut the way the cascade does and measures
whichever token actually answers. Declare --brand, and declare
--brand-foreground and --brand-ink beside it:
:root {
--brand: #b1976b;
--brand-foreground: hsl(
30 25% 12%
); /* the label printed on the fill, 4.5:1 */
--brand-ink: #8c6c3c; /* the same hue as words, 4.5:1 on the page */
}TypographyHighlight deliberately does not take this type. Its palette is
categorical — identity and kind, never state — which is the same distinction
theme.css draws between the earth swatches and the status tokens.
Every component that can be a link takes href and nothing more: Button,
Badge, Card, TypographyLink. One function behind all four —
resolveLink, exported from the root for the rare call site that styles someone
else's element:
| the href | renders | why |
|---|---|---|
/notes |
the router's Link |
client navigation, and the view transition survives |
#section |
a plain <a> |
the browser can already scroll there; routing it asks for a navigation |
https://… |
<a target="_blank" rel="noopener noreferrer"> |
it leaves the app |
mailto:, tel: |
<a>, no target |
it hands off to another app; no tab opens |
external overrides the scheme sniff (an absolute URL that is home, a relative
one that is not) and newTab overrides the target.
Do not pass an anchor through render. render={<a href="/x" />} reads as a
styling escape hatch and is a routing decision made in the wrong place: the
cloned anchor never reaches the router, so the page fully reloads and the view
transition is lost, and an off-site href gets no rel. Button now accepts
href, so this old pattern is no longer the way to do it. linkRules() in the
lint set flags the old form on Button, Badge and Card, and render keeps
its real job — an element that is genuinely not a link.
RailLink is the exception, and the comment in essay/rail.tsx says why: its
module is reached from contents.tsx, reading.tsx and layout.tsx, which have
to import in bare Node and in a test runner with no Next installed. Pulling the
router in there would break that, so a rail of routes still passes <Link>
through render. Its own links are #hash anchors, which want no router.
<Callout tone="warn" title="Rotate the key first" icon={KeyIcon}>
The old one stops working the moment the new one is issued.
</Callout>;
{
/* Docs density: reading-size body, accent rail */
}
<Callout density="editorial" tone="primary" title="Why this is safe">
Replication slots are consumed in order.
</Callout>;| prop | type | default |
|---|---|---|
tone |
Tone (see above) |
muted |
density |
"compact" | "editorial" |
compact |
title |
ReactNode |
— |
icon |
ComponentType<{ className?: string }> |
— (injected, so the package needs no icon set) |
action |
ReactNode |
a link or buttons under the body |
bodyClassName |
string |
for the one body that is not prose |
compact is the product form, with a 12px title over 12px body. editorial is
the docs form: body copy at reading size and a 3px accent rail, so the surface
around it can stay quiet. The icon is the title's mark, or
the body's first line's when there is no title.
This is a permanent explanation inside a panel, not a shadcn Alert, and it
should not announce itself to a screen reader every time a sheet opens.
<Button>Publish</Button> {/* solid, primary, md */}
<Button variant="outline">Cancel</Button> {/* outline implies neutral */}
<Button variant="ghost" tone="destructive" size="sm">Delete</Button>
<Button variant="ghost" size="sm" icon aria-label="More"><MoreIcon /></Button>
<Button size="xl" pill tone="brand" href="/notes"> {/* a link, routed */}
Read the notes
</Button>| prop | type | default |
|---|---|---|
variant |
"solid" | "soft" | "outline" | "ghost" | "link" |
solid |
tone |
Tone (see above) |
primary on solid, muted otherwise |
size |
"xs" | "sm" | "md" | "lg" | "xl" |
md |
icon |
boolean — a square box for a lone glyph |
false |
pill |
boolean — full-round corners |
false |
href |
string — makes it a link, routed by resolveLink |
— |
external |
boolean — override the scheme sniff |
from the href |
newTab |
boolean — override the target |
on for an http(s) href |
render |
ReactElement — an element that is neither a button nor a link |
a <button> |
A glyph passed as a direct child is one em of the label and centres on its
letters, so it takes no class; a size class on it is inert and lint says so. One
you wrap in an element of your own keeps the size you give it. Badge does the
same, and so does a TabGroup tab, whether the glyph arrives in its icon or in a
fragment as its label. A fragment counts as direct children in all three.
Variant is how much ink the button spends; tone is what the ink means. They
are independent, and that separation is what makes the API useful. A list that
mixes them — the default | secondary | destructive | ghost both apps used to
ship — can only express the pairs someone thought to add, so a quiet destructive
button had to be written by hand. Five variants against seven tones is
thirty-five pairs from twelve declarations, and the cross product happens in CSS.
tone is not this component's: it is the package's one semantic vocabulary,
listed under Tone above and taken by Callout and TypographyLink
too.
tone is the one axis whose default is not a constant. Filling a button in is
how a page says this is the action, so a solid button with nothing else stated
is the principal one; anything less than filled is chrome until a call site says
otherwise. That is not a convenience — 244 of the 246 buttons across both apps
that name a variant name outline or ghost, and every one wants the page's own
ink. A uniform default would have meant tone="muted" on all 244.
Sizes are one ladder on a 4px step, 24px to 40px. md is the product default,
lg and xl the marketing ones. icon and pill are both booleans because
both have exactly two states, and they compose: a round icon button is
icon pill.
A button that leads somewhere takes href, not an anchor. render={<a href="/x" />} was how this used to be written, and it is a routing decision
wearing a styling prop: the cloned anchor never reaches the router, so the CTA
full-page-reloads and loses the view transition, and an off-site href gets no
rel. href goes through the package's one rule — see Links — the
same one Card, Badge and TypographyLink use. The lint set flags
the old form.
Either way the element is not run through Base UI: it stamps role="button" on
whatever it renders, and that drops an anchor out of screen-reader link
navigation. render remains for an element that is genuinely neither — a
<label>, a menu item.
<Badge>Live</Badge> {/* solid, primary, sm */}
<Badge variant="soft" tone="destructive">Failed</Badge>
<Badge variant="outline" tone="warn">Needs review</Badge>
<Badge size="xs">12</Badge> {/* the toolbar count */}| prop | type | default |
|---|---|---|
variant |
"solid" | "soft" | "outline" | "ghost" |
solid |
tone |
Tone (see above) |
primary on solid, muted otherwise |
size |
"xs" | "sm" |
sm |
pill |
boolean |
false |
href |
string — a badge that leads somewhere, routed by resolveLink |
— |
The same two axes as Button, spelled the same way, so knowing one
component's vocabulary is knowing this one's. link is the single omission: badge
labels are not links.
Both apps had grown a private list instead. ssite's carried warning and
supertype — the package's warn and brand tones under invented names, which
is exactly the second vocabulary a design system exists to prevent — plus a
secondary that was bg-muted/80 text-foreground against a default of
bg-muted text-muted-foreground, a distinction no reader could put a name to.
viably's was a copy of the old button list, link variant and all.
Two rungs, not five: sm is the label beside a title, xs the figure beside a
toolbar control.
Disclosure / DisclosureGroup, a <details>/<summary> pair: the one way
to show and hide a row. No JavaScript, correct before hydration, and available to
an MDX author:
<DisclosureGroup type="single" defaultValue="Retries">
<Disclosure title="Retries">Three attempts, exponential backoff.</Disclosure>
<Disclosure title="Timeouts">30s, then the job is requeued.</Disclosure>
</DisclosureGroup>type is multiple (default) or single. Single-open mode uses the shared
name attribute browsers implement natively, so it costs no state.
defaultValue matches on the title string.
The panel opens and closes on a 200ms ease where the browser supports
::details-content and interpolate-size, and opens at once where it does not.
That was the whole case for a second, client-side accordion, so there is no
longer one.
It is the tab strip's line variant turned on its side: no box and no fill, a
hairline between rows, and one 2px mark in --tone-hue drawing itself down the
open row. Ink carries the state the way a tab label does, muted at rest and
--foreground open, and hover moves the ink and nothing else. tone on the
group inks that mark, and only it:
<DisclosureGroup tone="brand">…</DisclosureGroup>A row whose open state the app keeps, persisted or driven from elsewhere, is still
a Disclosure: <details> takes open and reports onToggle natively, so it
needs no prop of the package's and no client boundary of its own:
<Disclosure
title="Deals"
open={open.includes("deals")}
onToggle={(e) => setOpen("deals", e.currentTarget.open)}
>
…
</Disclosure>"use client";
<TabGroup
defaultValue="people"
tabs={[
{ value: "people", label: "People", content: <Roster /> },
{ value: "revenue", label: "Revenue", content: <Chart /> },
]}
/>;TabGroup is the one way to make tabs. Everything the package and both apps do
with tabs is this shape, including a preview/code switcher, which is TabGroup
with a wrapper in each content. The parts it is built from are not exported.
Build the tabs with .map when they are data, and put anything that frames the
strip and its panels (a card, a border) around the TabGroup.
With no content on any tab it is a picker: the strip alone, driving state
through value and onValueChange.
A chart's metric or a date range is this:
<TabGroup
size="sm"
value={metric}
onValueChange={setMetric}
tabs={metrics.map((m) => ({ value: m.key, label: m.label }))}
/>size is md (default) beside interface copy and sm in a toolbar or over a
chart: a 28px rail and 12px labels, stated once rather than as classes on the list
and every trigger.
It takes a variant: default draws a boxed segmented track, line
drops the surface and marks the active tab with an underline. Each variant states
its own box — a line strip wraps and its tabs are as wide as their labels;
boxed segments split one fixed-height rail evenly — so neither needs correcting
at the call site.
It also takes a tone, from the same seven every Button and Callout uses. The
tone inks the marker, and only the marker: on line, the underline and the
active tab's icon. The boxed track's marker is a card surface and a hairline that
SEGMENT keeps deliberately flat, so a tone there would be a colour with nothing
to paint. Labels stay --foreground either way — a label is read, not signalled.
<TabGroup
variant="line"
tone="brand"
defaultValue="speakers"
tabs={[
{
value: "gallery",
label: "Gallery",
icon: <Award />,
content: <Overview />,
},
{
value: "speakers",
label: "Speakers",
icon: <PriceChip />,
content: <Speakers />,
},
]}
/>icon is an element — <Award />, <PriceChip /> — one em of the label, seated in the
label's own gap, and taking the tone when its tab is active. An element rather than a
component because TabGroup is a client component: a component reference handed to it
from a server page is a function crossing the RSC boundary, and React refuses that. iconPosition moves it to "inline-end". Pass value with
onValueChange to drive the strip from outside.
value is stable across a relabel, which is the whole reason it exists — it is
what defaultValue and onValueChange speak.
The values' own type flows through TabGroup<V> to defaultValue, value and
onValueChange, so a picker can drive a union without a cast. Type the items
as TabItem<Range> and onValueChange receives a Range:
type Range = "7d" | "30d" | "90d";
const [range, setRange] = useState<Range>("30d");
<TabGroup
size="sm"
value={range}
onValueChange={setRange}
tabs={ranges.map((r): TabItem<Range> => ({ value: r, label: r }))}
/>;An MDX author writes <Tabs items={[…]}> with a <Tab> per panel, paired by
position. That shape belongs to the MDX map, not the component API.
<Steps>
<Step title="Install the CLI">…</Step>
<Step title="Authenticate">…</Step>
</Steps>It renders an <ol>, so a screen reader counts the steps and says how many are
left. The numerals on the page are a CSS counter rather than markup: reordering
renumbers them, and the digits stay out of the accessibility tree and out of
anything you copy, since the list already carries the count.
Three columns, at 0, 28 and 40px: the numeral, the spine, the copy. The numeral
sits in its own gutter rather than centred on the rail — centring it there means
covering the hairline with a plate in the page's colour, which is a component
asserting what surface it is on, and a visible chip the moment a step list lands
inside a card. Out in the gutter the rule runs unbroken and nothing has to know
the background. It is also the better setting: right-aligned and tabular-nums,
so 9 to 10 moves the digits and not the spine.
The rail is a pseudo-element rather than a left border, so the last step drops it
outright instead of painting it transparent. tone inks the numerals and nothing
else; title and body are TypographyLabel and TypographyMuted, so both read
--ink/--ink-muted and a step list on a card takes the card's ink.
The panel shape, with no copy in it: an accent along the top edge, a kicker, a headline, a sentence, a two-up grid of short points, and a rule with a control on it. An announcement, a release note, a status post, a credits block.
import { Bulletin, Ribbon, Button } from "@supertype.ai/foundations/blocks";
const POINTS = [
{
mark: "bg-fern",
ink: "text-fern-ink",
title: "Scheduled exports",
body: "…",
},
{
mark: "bg-stone",
ink: "text-stone-ink",
title: "Faster cold starts",
body: "…",
},
];
<Bulletin
accent={<Ribbon className="h-1.5 w-full" />}
eyebrow="Release 4.2"
headline="Exports run on a schedule now."
lede="Two things shipped this week."
points={POINTS}
action={
<Button href="/notes" variant="outline" size="sm">
Read the notes
</Button>
}
footnote="Rolling out through Friday."
/>;| prop | what it is |
|---|---|
variant |
"card" (the panel) or "line" (the same statement at one line: accent, action, sentence) |
accent |
drawn along the top edge, full width, before the padding starts |
eyebrow, headline, lede |
the opening |
points |
{ title, body, mark?, ink? }[] — mark and ink are class names |
action |
the control on the footer rule |
footnote |
the line opposite it |
children |
under the rule |
Every slot is optional and an omitted one renders nothing rather than an empty
box, so the same component covers a full credits panel and a headline with one
button under it. One point takes the width; two or more go two-up from sm.
mark and ink are class names rather than token names, so a point can carry
any hue the app has — bg-fern/text-fern-ink from theme.css, or one of your
own. Ribbon takes hues (default EDITORIAL_INKS, the eight categorical hues
as data) and states no size of its own, since a panel wants h-1.5 w-full and a
row wants h-1 min-w-20 flex-1 rounded-full.
Whatever you pass has to be a literal in your own source. Tailwind reads the files it scans as text, so a class assembled from a variable is a class it never generates — the same rule the package follows in its own files.
A compact statement block for a page, a section, or a footer.
import { Colophon } from "@supertype.ai/foundations/blocks";
<Colophon /> // the standard panel
<Colophon variant="line" /> // a compact rowThis component takes no configuration. Call it as <Colophon />. children
renders under the rule on the panel, where a printed colophon carries the site's
own credit — the faces it is set in, the people who wrote it, its licence — and
label translates the link's words.
The panel's own copy is not a prop: this is a mark, and a mark every site
rewrites is not a mark. That is a constraint on the words; it used to be a
constraint on the layout too, which was wrong — an announcement panel and a
credits panel are the same shape. The shape is Bulletin above,
and Colophon is a nine-line preset of it, the way TypographyProse is a
preset of TypographyP. If you want this look with your own words, use
Bulletin, not a reconfigured attribution.
It draws all eight categorical hues as a ribbon and inks two of them, so it is
the one place the palette appears whole. Those hues live in theme.css: an app
that imported tokens.css alone gets a panel with an invisible ribbon and no
error, which is the failure doctor exists to catch.
import { BuiltWithFoundations } from "@supertype.ai/foundations/blocks";
<footer className="flex items-center gap-4">
<span>© 2026 Your Company</span>
<a href="/privacy">Privacy</a>
<BuiltWithFoundations />
</footer>;A colophon is a block with something to say, and it needs a place to say it: a
credits page, an about page, the foot of a long article, a footer with room for
a row of its own. BuiltWithFoundations is one item in a row somebody else laid
out, sitting next to a privacy link and a copyright line, and it says only where
the site came from.
The test is what the surface is for. If the attribution is going somewhere that
already has its own columns, it is BuiltWithFoundations. If the block is the
thing being placed, it is Colophon. There is deliberately no Colophon
variant that renders the link alone: that would be a second name for a component
that already has one.
BuiltWithFoundations is a Button variant="outline" size="sm" carrying an
href, so it is the same control, the same hairline and the same radius rung as
every other button on the page — and the anchor is the button, which is where
the routing and the rel come from.
FoundationsMark is the eight-hue chip on its own, for a row that wants the
mark beside its own wording, and FOUNDATIONS_URL is the bare href for a
<link rel> or an analytics label.
The segmented picker as a set of class strings, for building your own control
that needs to match TabGroup's boxed strip:
import { SEGMENT } from "@supertype.ai/foundations/blocks";
<div className={cn(SEGMENT.track, "flex")}>
<button className={cn(SEGMENT.item, active ? SEGMENT.activeSurface : SEGMENT.idle)}>The keys are track, item, active, idle and activeSurface. There used to be a
sixth, dataActiveSurface, which was activeSurface respelled with data-active:
prefixes for an engine that marked its own trigger — one surface with two spellings,
changeable by half. TabGroup draws its marker as an element now, so both wear
activeSurface itself.
track carries shadow-recessed, the system's one inward shadow, over bg-background
rather than a fill of its own. The rail is the page pressed in, so the selected segment
can stay on the page plane and still be the brighter of the two — 4 points in light, 7 in
dark. A --muted rail gets that backwards in the dark theme, where --muted sits above
--card rather than below it.
Radii are concentric: rounded-md on the track over rounded-sm on activeSurface, the
2px of p-0.5 between them.
// mdx-components.tsx — the file convention @next/mdx calls with no arguments
import type { MDXComponents } from "mdx/types";
import { proseMdxComponents } from "@supertype.ai/foundations/mdx";
export function useMDXComponents(): MDXComponents {
return proseMdxComponents as MDXComponents;
}It is a plain object rather than a factory; the router and image component come from the package, and nothing remains to inject.
It binds the markdown elements (h1–h4, p, ul/ol, blockquote, a,
pre, img, table…) and exposes these for authors to call by hand:
MDX-rendered elements take no options. No call site exists to make the choice.
Retune them in CSS by moving the --text-* rung they sit on, or scope
.editorial over the subtree to move the whole ladder.
None of them carries a margin. Render the body inside ProseFlow and it spaces
each block by the one before it: --flow-space between blocks, --flow-section
above an h2 or a rule, --flow-lead under a heading. It is the contract Tailwind
Typography's .prose keeps, at zero specificity, so a utility on any child still
wins. EssaySection and EssayLayout read the same tokens, so a post and an
essay keep one rhythm.
<ReadingLayout headings={headings}>
<ProseFlow>
<Body />
</ProseFlow>
</ReadingLayout>Headings with an id link to themselves. Use rehype-slug for the ids and leave
out rehype-autolink-headings, whose anchor would nest inside the heading's own.
For code fences, add the Shiki plugin at build time:
// source.config.ts — runs in bare Node, so it must not reach React
import { rehypeProseCode } from "@supertype.ai/foundations/rehype";
export default defineConfig({
mdxOptions: { rehypePlugins: [rehypeProseCode] },
});It writes --shiki-light and --shiki-dark on every token instead of a fixed
colour, so one compiled document works in both themes and shiki.css decides
which applies.
