Adding a component
Pick a kebab-case name (badge, button-group, app-shell) and reuse it as the base class.
packages/admin-css/src/components/<name>.css— base class, variants, sizes, modifiers.packages/admin-css/src/components/index.css— add@import "./<name>.css";.- (Optional)
packages/admin-react/src/<Name>.tsx+ re-export fromsrc/index.ts. - (If React)
packages/admin-react/src/<Name>.test.tsx— smoke + interactions. apps/docs/src/content/docs/components/<name>.mdx—## Examples, then a## Referencetable per flavor.- A bullet under
## [Unreleased]inCHANGELOG.md. pnpm generate-skill— regenerate the bundle and commit it alongside the MDX.
No build-config changes needed.
The class-name contract
Section titled “The class-name contract”Both packages emit the same class names. <Button variant="primary" size="sm"> renders as <button class="btn btn-primary btn-sm">. Both must change together: a new CSS modifier needs a React prop, and a new React prop needs a class.
See Principles › Two flavors, one contract for the naming pattern and why a rename is a breaking change.
1. CSS
Section titled “1. CSS”Wrap rules in @layer components so they land in Tailwind’s components layer. Use @apply with semantic tokens (bg-primary, text-text-muted, border-border) — never reference Flexoki tones (--color-blue-600) directly from component code.
Fold the default variant’s look into the base class instead of a separate modifier, so vanilla class="badge" and React <Badge> match with no default-variant class.
@layer components { .badge { @apply inline-flex items-center justify-center gap-1 px-2 h-5 rounded-full text-xs font-medium leading-none whitespace-nowrap border border-transparent bg-surface-strong text-text; }
.badge-info { @apply bg-info-muted text-info border-info-muted; } .badge-danger { @apply bg-danger-muted text-danger border-danger-muted; }
/* md is the default; modifiers override */ .badge-sm { @apply h-4 px-1.5 text-[0.625rem] gap-0.5; } .badge-lg { @apply h-6 px-2.5 text-sm gap-1.5; }}If the component can host a leading icon, lay the root out with flex items-center gap-2 (or :has() to switch layout when a leading <i>/<svg> is present). No wrapper class: the icon goes directly into the root in both flavors.
.alert:has(> :is(i, svg):first-child) { display: grid; grid-template-columns: auto 1fr; column-gap: 0.5rem;}Register the file in packages/admin-css/src/components/index.css:
@import "./badge.css";That’s everything for vanilla. The docs site imports admin-css source, so the dev server picks it up immediately.
2. React (optional)
Section titled “2. React (optional)”Skip if the component is just CSS — Spinner, Footer, and the rail-only Sidebar parts are all-vanilla in places where Base UI adds nothing. Otherwise:
- Wrap a Base UI primitive (
@base-ui/react/button,/input,/field) when the component is interactive or needs a11y wiring. - Compose class names with the local
cnhelper (src/cn.ts). It prefixes admin’s own classes with_ao-(the scoped contract) while passing the consumer’sclassNamethrough verbatim. Don’t use bareclsx; an unprefixedbadgewon’t match the scoped._ao-badgerule. - Spread the rest of the props so callers can pass
id,aria-*, event handlers,ref, etc. - Take an
icon(andiconTrailingif structural) prop instead of letting callers pass icon JSX as children — see Icons below.
import { cn } from "./cn";import type { ComponentProps } from "react";import { renderIcon, type IconProp } from "./icon";
export type BadgeVariant = "neutral" | "info" | "success" | "warning" | "danger" | "primary";export type BadgeSize = "sm" | "md" | "lg";
export interface BadgeProps extends ComponentProps<"span"> { variant?: BadgeVariant; size?: BadgeSize; /** Leading icon. */ icon?: IconProp;}
export function Badge({ variant = "neutral", size = "md", icon, className, children, ...rest}: BadgeProps) { return ( <span className={cn( ["badge", variant !== "neutral" && `badge-${variant}`, size !== "md" && `badge-${size}`], className, )} {...rest} > {renderIcon(icon)} {children} </span> );}Export the component and every public type from packages/admin-react/src/index.ts:
export { Badge, type BadgeProps, type BadgeVariant, type BadgeSize } from "./Badge";Default-omit the default variant and size
Section titled “Default-omit the default variant and size”Skip the class when the value is the default, since the default look already lives in the base class. So <Badge>, <Badge variant="neutral">, and <Badge size="md"> all render the same DOM as the bare .badge vanilla markup.
Compound components: Object.assign dot-notation
Section titled “Compound components: Object.assign dot-notation”When the component has named parts (Card.Body, Field.Label, Sidebar.Item), define each part as a standalone function and stitch them onto the root with Object.assign:
export const Card = Object.assign(CardRoot, { Container: CardContainer, Body: CardBody, Title: CardTitle, Description: CardDescription, Actions: CardActions,});Export every part’s props type from index.ts.
High-level component + .Container escape hatch
Section titled “High-level component + .Container escape hatch”When a component has a meaningful container / inner-wrapper distinction in CSS (e.g. .card + .card-body) and shorthand props that auto-fill the wrapper:
- The default export (
<Card>) is opinionated — always renders the inner wrapper, accepts shorthand props (title,description,icon,actions) around children. <Card.Container>is the bare primitive, just the outer class, for layouts that don’t fit the default (multiple bodies, media headers, custom dividers).
Only use this split when there’s real layout variation. Leaf components (Button), linear layouts (Alert, Sidebar.Item), and Base UI compounds (Field, Select) don’t need it.
Components that can host an icon take an icon prop (and iconTrailing where applicable) accepting a component reference:
<Button icon={IconPlus}>Add</Button>Use renderIcon from src/icon.ts. It renders at size="1em" with aria-hidden by default, so SVG icons inherit the host font-size and match the Tabler webfont in the vanilla bundle. It also accepts a pre-instantiated element (icon={<IconPlus size={20} />}) when callers need a fixed size.
import { renderIcon, type IconProp } from "./icon";
export interface ButtonProps { icon?: IconProp; iconTrailing?: IconProp; children?: React.ReactNode; // …}
export function Button({ icon, iconTrailing, children }: ButtonProps) { return ( <button> {renderIcon(icon)} {children} {renderIcon(iconTrailing)} </button> );}Prefer this prop over passing icon JSX as children — the rendered DOM is identical, but the prop ensures consistent sizing and aria-hidden. See Conventions › Icons for the consumer-facing contract.
3. Tests
Section titled “3. Tests”Tests live next to the component as <Name>.test.tsx. Two shapes:
- Smoke — one
it("renders", ...)that mounts the component (with subparts) and asserts the root is queryable. Just “doesn’t throw”. - Interactions — controlled + uncontrolled paths for stateful components (
Input,Textarea,Checkbox,Switch,Radio,Select), plus a “parent ignores change → state stays put” case.
Use @testing-library/user-event, not fireEvent.
import { render, screen } from "@testing-library/react";import userEvent from "@testing-library/user-event";import { describe, expect, it, vi } from "vitest";import { Badge } from "./Badge";
describe("Badge", () => { it("renders", () => { render(<Badge>3</Badge>); expect(screen.getByText("3")).toBeInTheDocument(); });});src/test-setup.ts wires an explicit afterEach(cleanup), because RTL’s auto-cleanup hooks vitest at module-load time, before afterEach is exposed. Without this the DOM leaks across tests in the same file.
Tests are excluded from the published build via tsconfig.json and vite-plugin-dts; tsconfig.test.json type-checks them as the second half of pnpm check-types. css: false in vitest.config.ts — visual checks belong in the docs.
4. Docs page
Section titled “4. Docs page”Add apps/docs/src/content/docs/components/<name>.mdx. Two sections, both mandatory:
## Examples— one###per variation, each holding a:::examplewith anhtmlfence and atsxfence. No prose under the headings, and no page-level intro paragraph.## Reference—### Reactfor props and parts,### Vanillafor every class the new CSS file defines. This is not optional:pnpm check-docsfails on a class the component CSS doesn’t define, and reports every class that no Reference table mentions.
pnpm check-docsWriting docs has the full shape, the :::example directive, callouts, prose style, and the rules for writing a Reference table that reads well without the rendered preview.
5. Changelog
Section titled “5. Changelog”Add a bullet under ## [Unreleased] in the root CHANGELOG.md, tagged (css) / (react) / (both) so a consumer knows which dependency to bump. Skip docs-only and internal changes. The release workflow greps the version’s section out of CHANGELOG.md before it publishes, so a missing entry fails the release.
6. Regenerate the skill
Section titled “6. Regenerate the skill”pnpm generate-skillCommit skills/ in the same commit as the MDX change. See Writing docs › Regenerating the agent skill.