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
# 1. Copy the templatecp 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 serverpnpm 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:
- Lead — one paragraph saying what the primitive is and the user problem it solves. No “this component” filler.
- Component meta strip —
<ComponentMeta>with the import path, source path, Storybook story id. - When to use — bullets of concrete situations, then one bold “Don’t use for…” line pointing at the right primitive.
- Examples — one
<Preview>block per concept, with a matching code fence right below. Order simplest → richest. Common sections: Variants, Sizes, With icon, States, Composition. - API —
<ApiTable>listing every documented prop. Use TS types verbatim from the source. Don’t invent. - Design guidelines —
<DoDont>. Sharp bullets. Thedoitems are rules a reviewer would call out if violated; thedontitems are misuses we actually see in PRs. - Accessibility — keyboard map, ARIA notes, focus-ring rule, what NEEDS to be set on each call (e.g. icon-only buttons must set
tooltip). - Related — two-to-five other primitives, each with a one-line “use this when…” rule.
- 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:
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:
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. Usetext-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:
- Render a slimmed-down host in the
<Preview>— a 480×320 frame, fake nav, two rows of dummy data. - Don’t try to mirror an entire app. The page is teaching the primitive, not the app.
- 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. YourDo/Don'tblock must be consistent with these./guides/08-accessibility/— yourAccessibilitysection 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(inapps/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.