Component Library
@bc-solutions-coder/ui (packages/ui) is the shared, browser-only React component library both
frontends build their screens from. It is a wrapper layer, not a framework: every visual part
(the sonner-backed FailureToaster being the one documented exception) is a headless
Base UI primitive
(@base-ui/react) wrapped in a CVA class recipe written entirely in the
semantic Tailwind tokens @bc-solutions-coder/styles emits from packages/styles/branding.json. Behaviour and
accessibility come from Base UI; appearance comes from the fork's own theme; the package supplies
the glue and the house style.
The package is private (never published to a registry) and consumed as a workspace:* dependency.
Rebranding a fork changes packages/styles/branding.json — no component source changes.
The catalog
60 components, one folder per component under packages/ui/src/components/. The folder name is
also the import subpath.
| Group | Components |
|---|---|
| Forms and input | Button, Input, Textarea, Field, Fieldset, Form, Label, Checkbox, CheckboxGroup, Radio, RadioGroup, Select, Combobox, Autocomplete, Switch, Slider, NumberField, OTPField, Toggle, ToggleGroup, SimpleSelect |
| Overlays and menus | Dialog, AlertDialog, Drawer, Popover, Tooltip, PreviewCard, Menu, ContextMenu, Menubar |
| Layout and navigation | Accordion, Collapsible, Tabs, NavigationMenu, Toolbar, ScrollArea, Separator, Card (with CardHeader), CenteredCardLayout, PageContainer, PageHeader, EmptyState, ListCard, ListRow, QuietLink |
| Display and feedback | Text, MutedText, Badge, Avatar, Progress, Meter, ErrorBanner, NoticeBanner, FailureBanner, FailureToaster (with toastFailure) |
| Theming | ThemeProvider (with ThemeScript and useTheme), ThemeToggle |
| App wiring | ReadyIndicator, FocusOnNavigate, DocumentStyles, ForkAttribution, FailureMessagesProvider (with useFailureMessage) |
Text is the typography primitive the rest of the catalog composes onto: it owns the type scale
(display, title, heading, subheading, body, bodySm, caption, overline, code) and
the semantic colour set (default, muted, primary, accent, destructive, success,
onSidebar, onCard, onPrimary), with weight and align as independent axes.
MutedText is now literally <Text as="p" variant="bodySm" color="muted" /> — keep using it for
secondary copy, but reach for Text whenever you need a scale step or colour it does not name.
Four entries wrap no Base UI part; each names a stack the apps had been rebuilding by hand
(the failure surfaces FailureBanner, FailureToaster, and FailureMessagesProvider are a
fifth kind — the failure model's screen half, described in packages/ui/CLAUDE.md):
CardHeader(Card's folder, not one of its own) — a card's title-and-description pair. It owns the<h2>, so a screen composing it gets the card-heading step by construction instead of spelling out<Text as="h2" variant="subheading" color="onCard">and relying on lint to catch a mistake.titleTestIdtargets the heading element;data-testidlands on the wrapper.QuietLink— the muted secondary link: card footers, back-links, "Forgot password?". A plain<a>, because these navigate with real hrefs. Distinct fromButton variant="link", which is the primary-coloured stand-in for an action;QuietLinkrecedes.NoticeBanner— the non-destructive banner,tone="success" | "warning". A sibling ofErrorBannerrather than a tone on it:ErrorBannerwraps its children in a styled<p>, right for a sentence of failure text and wrong for a notice whose body may be a heading plus a link. SoNoticeBannerwraps nothing and you composeTextinside it.PageContainer—PageHeader's sibling: the column a page body sits in. It adds width and centring and nothing else, so a page writes nomax-w-*of its own; the rail, main column and padding around it belong to the app's layout route.
Browse them interactively with Storybook, which renders every component against the fork's real theme tokens:
pnpm --filter @bc-solutions-coder/ui storybook # http://localhost:6006
Consuming components
Root barrel vs. per-component subpath
The package ships both, from the same build — dist/ mirrors src/ one-to-one and the package is
marked sideEffects: false:
import { Button, Card, Dialog } from "@bc-solutions-coder/ui"; // root barrel — the default
import { buttonRecipe } from "@bc-solutions-coder/ui/button"; // per-component subpath
Use the root barrel for ordinary application code. Bundlers tree-shake it, so importing three components from the barrel ships three components.
Use the subpath in two cases:
- You need a component's CVA recipe. The barrel deliberately exports components and their prop
types only; a recipe (
buttonRecipeand itsButtonRecipeProps) is reachable through the subpath alone, so styling internals never widen the package's headline API. - The module graph is not tree-shaken. A dev server or a Vitest run links the whole barrel,
including components you never render. If a spec stubs a dependency that some unrelated barrel
member imports, the barrel fails to link —
packages/navigation/src/app-nav.tsximports@bc-solutions-coder/ui/navigation-menufor exactly this reason (the barrel also pulls inFocusOnNavigate, which needs router context its specs do not provide).
Single-part and multi-part components
Simple components are a single export taking the native element's props plus the recipe's variants:
<Button variant="destructive" onClick={onDelete}>
Delete project
</Button>
Multi-part components export one namespace object whose keys mirror Base UI's part names exactly, so the Base UI documentation for a component is also the documentation for Wallow's:
<Dialog.Root>
<Dialog.Trigger>Delete project</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Popup>
<Dialog.Title>Delete project</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
Composing Button onto a link
A navigation styled as a button is composed through Base UI's render prop, either onto an
intrinsic anchor or onto a router Link:
<Button render={<a href="/terms" />}>Terms</Button>
<Button render={<Link to="/dashboard/organizations" />}>Browse organizations</Button>
Do not pass a role yourself. Base UI's useButton merges role="button" onto every
non-native element it composes onto, which announced these navigations as actions and dropped them
out of a screen reader's links list while the href still worked — a WCAG 2.2 SC 4.1.2
Name/Role/Value mismatch. Button now measures the element it actually mounted and supplies
role="link" as a default whenever that element is an anchor carrying a destination. It covers
both shapes above, because a component-typed render only reveals its tag once mounted, and it
re-measures every render, so a control whose href disappears while a request is in flight stops
being a link for exactly that long.
The role is a default, not an override — it is spread before your props — so a caller who genuinely
needs role="menuitem" still wins. A hand-written role="link" is now redundant, and
role={undefined} deletes the role="button" a composed <div> depends on. Assert the outcome
with getByRole("link", { name }) rather than restating the role in the markup.
surface — which palette a component paints from
variant says what kind of control this is; every one of its arms paints from the page palette.
surface says which palette it paints from, which is a different question the moment a control
is dropped onto the fork's inverted rail — a secondary button there is a light chip on a dark
surface. The axis has two arms, page (the default) and sidebar:
<NavigationMenu.Link surface="sidebar" render={<Link to="/dashboard" />}>Overview</NavigationMenu.Link>
<ThemeToggle surface="sidebar" />
<ErrorBanner surface="sidebar">{message}</ErrorBanner>
It is carried today by buttonRecipe (and so by ThemeToggle, which composes Button),
errorBannerRecipe, and navigationMenuLinkRecipe. navigationMenuTriggerRecipe does not have
it yet — no app renders a trigger, so it is a known gap rather than a defect.
packages/navigation/src/app-nav.tsx is the reference example, passing it at both rail call
sites; the ErrorBanner in apps/wallow-web/src/shared/components/SignOut.tsx is the third
surface="sidebar" in the shell.
Reach for surface instead of hand-writing an inversion (bg-foreground text-background) in a
className: the recipe restates every colour dimension a variant arm can set, because
tailwind-merge only drops the classes you actually conflict with and any dimension left unnamed
stays a page colour on the rail.
Overriding styles
Every part merges its recipe with your className through tailwind-merge, so the value you pass
last wins and utilities you do not mention survive:
<Button className="w-auto rounded-full" /> // keeps the recipe's colours and typography
Reach for a token utility (bg-muted, text-destructive) rather than a raw colour. If the token you
want does not exist, add it in packages/styles first — see
Adding a New Design Token.
One CSS import is required
Tailwind v4 does not scan node_modules, so an app that renders these components must import the
package's @source declaration from its CSS entry, or every component renders unstyled:
@import "@bc-solutions-coder/styles/styles.css";
@import "@bc-solutions-coder/ui/source.css";
@source "./";
See Styling and Tailwind Setup for the full rationale.
Form controls come through @bc-solutions-coder/forms
An app rarely renders Field, Input, Select or Checkbox directly. @bc-solutions-coder/forms
sits one layer above this package and ships those controls pre-bound to TanStack Form state, zod
validation and derived testids — reach for it first, and drop to the raw parts only for a control the
catalog has no field for. See Forms. The dependency runs one way: forms imports ui,
never the reverse.
Do not mock it
App specs must never replace @bc-solutions-coder/ui with stubs. The components run in the same
real headless Chromium the app's own specs do, and a stub is how a passing spec starts hiding a
broken screen. See .claude/rules/TESTING.md.
Theming and dark mode
packages/styles emits a :root, a .dark and a .light block from packages/styles/branding.json, and the
catalog's three theming exports are what make them reachable. An app wires ThemeScript and
ThemeProvider once in its root document — see
Dark Mode for that wiring — after which any screen can read or change
the theme:
import { ThemeToggle, useTheme } from "@bc-solutions-coder/ui";
<ThemeToggle />; // cycles light -> dark -> system
const { mode, preference, setPreference } = useTheme();
preference is what the visitor asked for ("light", "dark" or "system"); mode is the
scheme currently painted. They are different values on purpose: "system" is the default and the
state a control must be able to return to, which is why ThemeToggle cycles through three states
rather than toggling two and carries no aria-pressed. Its current state is exposed to tests as
data-theme-preference. Passing both preference and onPreferenceChange makes it fully
controlled, which is how a story renders one face of the control without a real ThemeProvider or
a localStorage round-trip.
The mode class must be on
document.documentElement. Wrapping a subtree in<div className="dark">compiles, renders, and paints the light palette — see Scoping dark mode for why. Anything that needs to render or assert against a scheme has to stamp the class on the document element itself and clean up after itself, since every story and every spec in a file shares one document. The catalog's reference implementation ispackages/ui/.storybook/scheme-decorators.tsx— alightScheme/darkSchemedecorator pair that stamps the class in a layout effect and removes it on unmount — paired withexpectSchemefrom.storybook/scheme-assertions.ts, which measures that a scheme-scoped story paints the palette it claims. Copy that pair rather than inventing a wrapper.
Adding a component
Every component folder has the same five files. Copy the nearest existing component — a simple one
like button, a multi-part one like dialog — rather than starting from scratch.
packages/ui/src/components/<name>/
├── <name>.tsx # the parts: Base UI part + cn(recipe(), className) + full prop passthrough
├── <name>.styles.ts # CVA recipes ONLY — no JSX, no React import
├── <name>.stories.tsx # Storybook stories: this component's render/interaction coverage
├── <name>.test.tsx # behavioural edges a story cannot express
└── index.ts # folder barrel: the .tsx exports, then the .styles.ts exports
The steps:
- Check the real anatomy. Read the installed package's
<component>/index.parts.d.tsfor the authoritative part list; do not guess it from a design document. - Write the recipe in
<name>.styles.tsusing only semantic token utilities (bg-primary,text-muted-foreground,border-border, …). Style state off Base UI'sdata-*attributes (data-[disabled],data-[open]), not CSS pseudo-classes, so the recipe still applies when a caller composes the part onto another element withrender. - Wrap each part in
<name>.tsx, importing the Base UI part from its own subpath (@base-ui/react/<name>). Multi-part components export one namespace object mirroring Base UI's part names; parts that need no styling pass through unwrapped. - Register the Base UI subpath in
baseUiSubpathsinpackages/ui/vitest.config.ts. This is required, not an optimisation — without it the test run pre-bundles a second copy of React and the specs fail. - Write the stories, covering every variant and state, with a
play()function for interactive components. These are the component's test coverage. - Add
<name>.test.tsxonly for what a story cannot assert. - Export it: the folder's
index.ts, then the two catalog files that must move together in the same commit — the root barrelsrc/index.ts, andPUBLIC_RUNTIME_EXPORTS+PublicTypeExportsinsrc/index.test.ts. Growing one alone turns the other red. - Test it:
pnpm --filter @bc-solutions-coder/ui test. No build first — in-repo theexportsmap resolves tosrc/, so app suites see the change immediately and there is no stale bundle to test against.
The subpath export needs no manifest edit: package.json maps ./* to src/components/*/index.ts
in-repo (and to dist/components/*/index.js at publish time, via publishConfig), so the folder
you created is already importable as @bc-solutions-coder/ui/<name>.
packages/ui/CLAUDE.md holds the full contributor detail — package layering, the recipe/JSX split,
and the Vitest browser-mode pitfalls specific to headless components.
How it is tested
pnpm --filter @bc-solutions-coder/ui test runs three Vitest projects:
| Project | Runs | Environment |
|---|---|---|
node |
pure-logic *.test.ts |
Node |
browser |
component *.test.tsx |
headless Chromium, no stylesheet loaded |
storybook |
every *.stories.tsx, via @storybook/addon-vitest |
headless Chromium with the real Tailwind build and fork theme |
Stories carry the render and interaction coverage; *.test.tsx covers the edges a story cannot
express. Because the storybook project compiles real CSS, it is also the only place a spec can
assert that a recipe utility actually paints. Nothing in the package is mocked — real Base UI parts,
a real browser, real design tokens. See .claude/rules/TESTING.md.