Skip to content

Adding a component

Pick a kebab-case name (badge, button-group, app-shell) and reuse it as the base class.

  1. packages/admin-css/src/components/<name>.css — base class, variants, sizes, modifiers.
  2. packages/admin-css/src/components/index.css — add @import "./<name>.css";.
  3. (Optional) packages/admin-react/src/<Name>.tsx + re-export from src/index.ts.
  4. (If React) packages/admin-react/src/<Name>.test.tsx — smoke + interactions.
  5. apps/docs/src/content/docs/components/<name>.mdx## Examples, then a ## Reference table per flavor.
  6. A bullet under ## [Unreleased] in CHANGELOG.md.
  7. pnpm generate-skill — regenerate the bundle and commit it alongside the MDX.

No build-config changes needed.

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.

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.

packages/admin-css/src/components/badge.css
@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.

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 cn helper (src/cn.ts). It prefixes admin’s own classes with _ao- (the scoped contract) while passing the consumer’s className through verbatim. Don’t use bare clsx; an unprefixed badge won’t match the scoped ._ao-badge rule.
  • Spread the rest of the props so callers can pass id, aria-*, event handlers, ref, etc.
  • Take an icon (and iconTrailing if structural) prop instead of letting callers pass icon JSX as children — see Icons below.
packages/admin-react/src/Badge.tsx
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";

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.

Tests live next to the component as <Name>.test.tsx. Two shapes:

  1. Smoke — one it("renders", ...) that mounts the component (with subparts) and asserts the root is queryable. Just “doesn’t throw”.
  2. 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.

Add apps/docs/src/content/docs/components/<name>.mdx. Two sections, both mandatory:

  • ## Examples — one ### per variation, each holding a :::example with an html fence and a tsx fence. No prose under the headings, and no page-level intro paragraph.
  • ## Reference### React for props and parts, ### Vanilla for every class the new CSS file defines. This is not optional: pnpm check-docs fails on a class the component CSS doesn’t define, and reports every class that no Reference table mentions.
Terminal window
pnpm check-docs

Writing 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.

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.

Terminal window
pnpm generate-skill

Commit skills/ in the same commit as the MDX change. See Writing docs › Regenerating the agent skill.