@ctrl-ui/react
Every Control UI component, block, and primitive with neutral defaults. The package contains no skin presets and needs no provider or skin config. Use the registry when you want to own and edit the component source.
Install
bun add @ctrl-ui/react
Requires React 19 and Tailwind CSS v4. Wire the styles once in your global CSS:
@import "tailwindcss";
@import "@ctrl-ui/react/styles/index.css";
@source "../node_modules/@ctrl-ui/react/dist";
Use
import { Button } from "@ctrl-ui/react/ui/button";
<Button>Save</Button>
Import paths mirror the registry tree. To ship less CSS, import styles/theme.css and only the recipes and effects your components use.
Text and class names
Render text with Text and Heading. Size, tone, and weight are props; className carries layout only:
import { Text } from "@ctrl-ui/react/ui/typography";
<Text size="caption" tone="muted">Saved</Text>
Merge class names with the package's cn. A bare twMerge reads text-micro as a text color and silently drops it next to text-muted-foreground. Add your own type rungs on top of withControlUiMerge instead of starting a second merger:
import { withControlUiMerge } from "@ctrl-ui/react/lib/cn";
import { extendTailwindMerge } from "tailwind-merge";
const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["hero"] }] } } }, withControlUiMerge);
Controls size their own text: pick the control's size, or set its --cui-*-font-size knob in your theme, rather than wrapping the label in a text-* span.
Lint rules
These rules keep call sites honest. They come from one policy (lint/class-policy.mjs) and ship for Biome, ESLint, and oxlint, except the stylesheet rule, which is Biome only:
no-component-style-classeserrors whenclassNameon a component imported from@ctrl-ui/react(or acomponents/control-uidirectory) sets what the component owns: colors, type, padding, borders, radius, shadows, filters, state styles such ashover:anddata-[…]:, arbitrary properties that paint ([background:…]), or!important. Everytext-*class counts except alignment and wrapping (text-center,text-pretty,text-ellipsis…). The rule also reads string constants theclassNamereferences in the same file, and theclassNameof the element passed to the component'srenderprop. On any other element, it errors on classes that reach into a component's parts, such as[&_[data-slot=trigger]]:or*:data-[slot=icon]:. Layout classes (flex,gap-*, widths, heights, margins, placement) stay allowed. Restyle through the component's props or its--cui-*knobs instead.no-component-style-propsapplies the same boundary tostyle:--cui-*knobs and layout keys pass, while paint keys error (color,background,font*,padding*,border*,boxShadow,opacity,filter…). It also checks same-file constant objects and the element passed torender. When the paint is data, such as a user-picked fill, put it on a plain element of your own and let the component inherit it.no-component-part-csserrors when a stylesheet paints a component part: a rule whose subject is[data-slot],[data-control-family], or another[data-control…]attribute, including nested&rules and@apply. Setting the part's--cui-*knobs and layout there stays allowed. The preset skips installed Control UI source undercomponents/control-ui.- Paint-free components own no look, so both component rules treat them like plain elements:
RevealScopecan be the painted row or card itself, throughclassName,style, or itsrenderelement. Part selectors on it still error, andRevealkeeps the full rule because its recipe owns opacity. no-raw-type-classeserrors on text sizes in anyclassName: the type rungs, Tailwind'stext-xs…text-9xl, and arbitrarytext-[13px]. It reaches beyond Control UI, so adopt it folder by folder while you migrate.
The Biome rules need Biome 2.5.0 or later: the preset scopes the stylesheet rule with plugin includes.
Biome, extending the shipped preset so rules added later arrive with the package update:
{
"extends": ["@ctrl-ui/react/biome"],
"overrides": [{ "includes": ["src/features/**"], "plugins": ["./node_modules/@ctrl-ui/react/lint/no-raw-type-classes.grit"] }]
}
ESLint flat config:
import controlUi from "@ctrl-ui/react/eslint-plugin";
export default [
{
plugins: { "control-ui": controlUi },
rules: { "control-ui/no-component-style-classes": "error", "control-ui/no-component-style-props": "error" },
},
];
oxlint, through its JS plugin support:
{
"jsPlugins": ["@ctrl-ui/react/eslint-plugin"],
"rules": { "control-ui/no-component-style-classes": "error", "control-ui/no-component-style-props": "error" }
}
When your app imports components through its own wrapper package or folder, declare that path so the rules follow those imports too:
const options = { importSources: ["@acme/ui/components/"] };
rules: { "control-ui/no-component-style-classes": ["error", options], "control-ui/no-component-style-props": ["error", options] }
Biome's Grit plugins take no options, so the package ships control-ui-grit to write the rules with your paths. Run it on install so a package update regenerates them, gitignore .control-ui, and list the generated no-component-style-classes.grit, no-component-style-props.grit, and no-component-part-css.grit from ./.control-ui/ under plugins in place of the preset:
{ "scripts": { "postinstall": "control-ui-grit --import-source=@acme/ui/components/ --out=.control-ui" } }
Customize
Your application owns its theme. Override the library's neutral defaults in your CSS:
:root {
--primary: oklch(0.45 0.18 260);
--radius: 4px;
}
.dark {
--primary: oklch(0.78 0.1 260);
}
Component knobs customize one instance:
<Button style={{ "--cui-button-radius": "4px" }}>Save</Button>
For scoped themes, behavior choices, or adornments, pass your own ControlUiSkin to the optional provider. Define configurations containing render functions in a client module:
"use client";
import type { ReactNode } from "react";
import type { ControlUiSkin } from "@ctrl-ui/react/skin";
import { SkinProvider } from "@ctrl-ui/react/skin-provider";
const skin: ControlUiSkin = { id: "acme", indicators: { sidebar: "hover" } };
export function AppTheme({ children }: { children: ReactNode }) {
return <SkinProvider skin={skin}><div data-skin="acme">{children}</div></SkinProvider>;
}
Scope the corresponding CSS to [data-skin="acme"]. Portalled surfaces carry the provider's id, so they receive the same theme. With no provider, portals inherit the root theme. Each provider owns its subtree; there is no global skin singleton.
Optional peers
Install a peer only when you import the component that needs it.
| Component | Peer |
|---|---|
ui/calendar | react-day-picker |
ui/chart | @tanstack/charts |
ui/code, ui/code-diff | @tanstack/react-virtual, diff, shiki |
ui/command | cmdk |
ui/flow | @xyflow/react |
ui/markdown | streamdown |
ui/phone-input | react-phone-number-input, libphonenumber-js, zod |
ui/resizable | react-resizable-panels |
chat-composer | prosemirror-* |
email/email, email/templates | react-email |
ui/flow also needs the React Flow structural stylesheet, loaded under the recipes:
@import "@xyflow/react/dist/base.css" layer(base);