Skip to content

Authoring component pages

How to add a /components/<name>/ page that shows a live preview, the API, and the design rules — like /components/button/.

TL;DR

Terminal window
# 1. Copy the template
cp apps/docs/src/templates/component-page.mdx apps/docs/src/content/docs/components/<name>.mdx
# 2. Fill it in (replace every {{TOKEN}} and the leading "HOW TO USE" comment block)
# 3. Run the dev server
pnpm dev:docs
# → http://localhost:4321/components/<name>/

The sidebar updates automatically (Components is autogenerated).

What goes on a component page

The order matters. We picked it to match how a developer reaches the page:

  1. Lead — one paragraph saying what the primitive is and the user problem it solves. No “this component” filler.
  2. Component meta strip<ComponentMeta> with the import path, source path, Storybook story id.
  3. When to use — bullets of concrete situations, then one bold “Don’t use for…” line pointing at the right primitive.
  4. Examples — one <Preview> block per concept, with a matching code fence right below. Order simplest → richest. Common sections: Variants, Sizes, With icon, States, Composition.
  5. API<ApiTable> listing every documented prop. Use TS types verbatim from the source. Don’t invent.
  6. Design guidelines<DoDont>. Sharp bullets. The do items are rules a reviewer would call out if violated; the dont items are misuses we actually see in PRs.
  7. Accessibility — keyboard map, ARIA notes, focus-ring rule, what NEEDS to be set on each call (e.g. icon-only buttons must set tooltip).
  8. Related — two-to-five other primitives, each with a one-line “use this when…” rule.
  9. Storybook deep link▶ Open <Name> stories in Storybook.

The JSX-as-prop trap (read this)

You can pass strings, numbers, and booleans into a React component’s named props from MDX. You cannot pass JSX into a named prop, because Astro’s MDX runtime hands the prop to the React island as an astro:jsx object, which React-DOM SSR throws on with “Objects are not valid as a React child.”

{/* ❌ Breaks at SSR — icon is astro:jsx, not React JSX */}
<EmptyState
client:load
icon={<IconInbox className="size-10" />}
action={<Button>Create</Button>}
title="No items"
/>

Children work — Astro forwards children through the slot mechanism as HTML, so this is fine:

{/* ✅ Children pass through OK */}
<Button client:load>
<IconPlus />Create
</Button>

When a component genuinely needs JSX-as-prop (icon + action are exactly that shape), wrap the example in a small .tsx demo file under apps/docs/src/demos/ and import that as a single island:

apps/docs/src/demos/MyComponentDemo.tsx
import { EmptyState } from '@na/ui/components/EmptyState';
import { Button } from '@na/ui/components/button';
import { IconInbox } from '@tabler/icons-react';
export function BlankSlate() {
return (
<EmptyState
icon={<IconInbox className="size-10" />}
title="No items"
action={<Button>Create</Button>}
/>
);
}
{/* In MDX */}
import { BlankSlate } from '../../../demos/MyComponentDemo';
<Preview><BlankSlate client:load /></Preview>

Inside the .tsx file everything is real React JSX, so JSX-as-prop is fine. The MDX renders one island per demo. Show the intended code (with JSX-as-prop) in the adjacent code fence — that’s what consumers will copy.

apps/docs/src/demos/EmptyStateExamples.tsx is a worked example of this pattern.

The split-island context trap (read this)

A second class of MDX bug (cousin to the JSX-as-prop trap above): if a Radix component family relies on shared React context across its parts, every part must hydrate inside the same island.

Each <Component client:load /> you write in MDX is a separate Astro island — and React contexts don’t cross island boundaries. So this breaks with Error: <Item> must be used within <Root>:

{/* ❌ Two islands. RadioGroupItem can't see RadioGroup's context. */}
<RadioGroup client:load defaultValue="weekly">
<RadioGroupItem client:load value="daily" />
<RadioGroupItem client:load value="weekly" />
</RadioGroup>

Wrap the whole composition in a .tsx demo. Inside one component file, everything renders as a single island and the context flows normally:

apps/docs/src/demos/RadioGroupExamples.tsx
export function Basic() {
return (
<RadioGroup defaultValue="weekly">
<Label className="flex items-center gap-2">
<RadioGroupItem value="daily" />
Daily
</Label>
</RadioGroup>
);
}
{/* In MDX */}
import { Basic } from '../../../demos/RadioGroupExamples';
<Preview><Basic client:load /></Preview>

Components that need this pattern: RadioGroup/RadioGroupItem, Select/SelectTrigger/SelectItem, Avatar/AvatarImage/AvatarFallback (+AvatarBadge/AvatarGroup), Tooltip/TooltipTrigger/TooltipContent, Tabs/TabsList/TabsTrigger/TabsContent, Accordion/AccordionItem, Dialog/DialogTrigger/DialogContent — any Radix primitive whose Item reads context from its Root.

Components that don’t: Card/CardHeader/CardContent (just styled divs, no context), Alert/AlertTitle/AlertDescription, Form* slot wrappers (they share via useFormContext but only inside one tree, which works as long as <Form> itself is one island).

When in doubt: extract to a TSX demo. The cost is one extra file; the benefit is a known-working hydration tree.

Hydration: when to use client:load

Every React component you render in .mdx is an Astro island. Without a client:* directive, it renders once on the server and never hydrates — fine for static visuals, broken for anything interactive.

Use client:load when the example needs to:

  • Respond to clicks / hover / focus (any Tooltip, Popover, Dialog, DropdownMenu).
  • Animate (Skeleton with motion, Loader spinner, anything with animate-).
  • Hold local state (Tabs, Accordion, Carousel, AutosizeTextarea).
  • Open a portal (Dialog, Sheet, Tooltip, Popover, DropdownMenu).

Use no directive (server-only) when the example is purely visual and never needs JS — pure-CSS Badges, Cards, Separators, Skeletons without animation.

When in doubt, use client:load. The cost is small for examples and the failure mode (silent non-interactivity) is annoying.

Conventions to honor

These are not negotiable — they’re the same rules the spec enforces in /guides/04-components/:

  • Tokens only. Never text-gray-500, bg-blue-600, raw hex. Use text-foreground, bg-primary, etc.
  • 8px spacing ladder. Don’t introduce magic margins.
  • Kebab-case URL slug. File confirm-dialog.mdx → URL /components/confirm-dialog/. Match the import path’s name.
  • One default-variant button per surface in your examples too. Don’t litter previews with primaries.
  • Don’t restate the visible label in tooltips, descriptions, or accessibility prose.
  • Examples live and die in the page. A primitive without a docs page can’t ship.

Anti-patterns we’ve seen and rejected

  • Embedding a Storybook iframe instead of importing the component. Defeats the whole “live, real” promise. Don’t.
  • Using screenshots for “consistency”. The component is the screenshot — render it.
  • Padding the API table with comments instead of moving them to the description column. Comments are noise; descriptions are searchable.
  • Long Do/Don’t paragraphs. If a bullet runs longer than two lines, split it or rewrite. Reviewers should be able to scan in 5 seconds.
  • Stacking three <Preview> blocks for variants of the same example. One <Preview> per concept. Variants of the same concept go in the same frame.

Adding components that need a special context

Some primitives only make sense inside a layout (Sidebar, PageHeader, FocusLayout, BoardView, DataTable, etc.). For those:

  1. Render a slimmed-down host in the <Preview> — a 480×320 frame, fake nav, two rows of dummy data.
  2. Don’t try to mirror an entire app. The page is teaching the primitive, not the app.
  3. If the host is so big that it dwarfs the primitive, link out to a Storybook story instead and skip the in-page Preview.

Required reading before authoring

  • /guides/04-components/ — the canonical spec for primitives. Your page’s tone should match.
  • /guides/02-foundations/ — color, type, spacing tokens. Your Do/Don't block must be consistent with these.
  • /guides/08-accessibility/ — your Accessibility section is a contract; align it with this guide.
  • The actual component source in packages/ui/src/components/<name>.tsx. Your prop table must match.

Reviewer’s eye

A page is ready to merge when:

  • The <Preview> blocks render every variant a real consumer would type.
  • pnpm typecheck (in apps/docs) returns 0 errors.
  • The Storybook deep link works (story exists, slug correct).
  • Light + dark both look right (toggle in the top-right of any docs page).
  • The page reads start-to-finish in under three minutes for someone who’s never seen the primitive.