Styling
Every component accepts className and merges it with cn(). Customize with utilities mapped from tokens, never with hard-coded values.
className & cn()
Your className always goes last in the component's cn(...), so it wins over any default class from the same group.
<Button className="rounded-circular px-5">Rounded</Button>
<Button appearance="primary" className="min-w-40">Wider</Button>
<Badge className="tracking-wide uppercase">New</Badge>cn() in src/lib/cn.ts is clsx + tailwind-merge, configured to know Yugen's scales. Without that, plain tailwind-merge treats text-body1 as a text color and drops it when it meets text-foreground-1, so the text falls back to the default size. The actual output of cn() on this page:
| Component classes | className passed in | cn() returns |
|---|---|---|
text-body1 text-foreground-2 | text-foreground-1 | text-body1 text-foreground-1 |
px-3 py-2 | px-5 | py-2 px-5 |
rounded-medium shadow-4 | rounded-circular shadow-16 | rounded-circular shadow-16 |
bg-background-1 hover:bg-background-1-hover | bg-brand-background-2 | hover:bg-background-1-hover bg-brand-background-2 |
cn from @/lib/cn; don't use twMerge directly. If you add new tokens (font size, shadow, duration…), declare them in extendTailwindMerge in cn.ts.Token utilities only
Tailwind's default palette, radius, shadows, font sizes and easing are turned off in index.css. Only utilities mapped from Fluent tokens remain.
| For | Use | Don't use |
|---|---|---|
| Secondary text color | text-foreground-2 | text-gray-600, text-[#…] |
| Primary button background | bg-brand-background | bg-pink-600, bg-[#…] |
| UI font size | text-body1 | text-sm, text-[14px] |
| Control radius | rounded-medium | rounded-md, rounded-[4px] |
| Menu shadow | shadow-16 | shadow-lg |
| Hover duration | duration-faster | duration-100 |
| Dark theme | data-theme="dark" | dark:bg-… |
Spacing
Spacing uses Tailwind's numeric scale: p-3, gap-2, mt-6. The ten steps below match Fluent's spacing ramp, so moving from Fluent UI is a rename: spacingHorizontalM → px-3. Keep padding, margin and gap within these ten steps; element sizes (h-8, size-5, w-64) use the other numbers.
The old uppercase aliases (p-M, gap-SNudge) still work but are deprecated since 0.10 and will be removed in 1.0. Replace them using the right-hand column of the table.
Overrides & variants
For a one-off override, use className. For something you repeat in many places, package it as a variant with class-variance-authority (cva).
Since the source lives in your project, the shadcn way is to edit the variants table in Button.tsx directly. To keep the original file untouched so updates stay easy, wrap the component and add variants with cva:
import { cva, type VariantProps } from "class-variance-authority";
import { Button, type ButtonProps } from "@/components/ui";
import { cn } from "@/lib/cn";
const pillVariants = cva("rounded-circular", {
variants: {
tone: {
neutral: "",
brand:
"border-transparent bg-brand-background-2 text-brand-foreground-2 hover:bg-brand-background-2-hover active:bg-brand-background-2-pressed",
},
},
defaultVariants: { tone: "neutral" },
});
type PillButtonProps = ButtonProps & VariantProps<typeof pillVariants>;
export function PillButton({ tone, className, ...props }: PillButtonProps) {
return <Button className={cn(pillVariants({ tone }), className)} {...props} />;
}
<PillButton>Neutral</PillButton>
<PillButton tone="brand" icon={<Star20Regular />}>Starred</PillButton>Components also export their variant functions so you can style other elements: buttonVariants, linkVariants, imageVariants, toggleButtonVariants. For example <a className={buttonVariants({ appearance: "primary" })}>, although <Button asChild> is usually shorter (see Slots).
Data attributes & important
Radix writes state to the DOM as data attributes. Style states with data-[…] variants instead of React state.
<MenuTrigger asChild>
<Button
appearance="subtle"
icon={<ChevronDown20Regular className="transition-transform duration-normal" />}
iconPosition="after"
className="data-[state=open]:bg-subtle-pressed data-[state=open]:text-foreground-1 data-[state=open]:[&_svg]:rotate-180"
>
Sort
</Button>
</MenuTrigger>
{/* A medium Button sets [&_svg]:size-5, so a 16px icon needs the ! suffix (Tailwind v4) */}
<Button icon={<Star20Regular className="size-4!" />}>16px icon in a medium button</Button>| Attribute | Set by | Example variant |
|---|---|---|
data-state="open | closed" | Menu, Popover, Dialog, Tooltip, Accordion, Collapsible trigger & content | data-[state=open]:bg-subtle-pressed |
data-state="checked | unchecked" | Checkbox, Switch, Radio, MenuItemCheckbox | data-[state=checked]:bg-brand-background |
data-state="active" | TabsTrigger, TabsContent | data-[state=active]:font-semibold |
data-highlighted | Menu and Dropdown items (keyboard / mouse) | data-highlighted:bg-background-1-hover |
data-disabled | Disabled items, Slider, Dropdown trigger | data-disabled:text-foreground-disabled |
data-side="top | bottom…" | Floating surfaces after positioning (including when flipped) | data-[side=top]:origin-bottom |
Use the ! suffix (Tailwind v4 syntax, placed at the end of the class) only when a parent component sets a more specific selector such as [&_svg]:size-5. Don't sprinkle ! to beat the component's own classes; your className already comes last in cn().
Only the CSS of the components you use
theme.css makes Tailwind scan every component in the package, so every app carries the CSS of every component. The CLI's css command reads the imports in your code and writes a file with only those components.
npx @yugen-ui/cli@latest cssIt writes yugen-ui.css next to your main CSS file and replaces @import "yugen-ui/theme.css" with @import "./yugen-ui.css". yugen init sets this up for new projects.
/* src/yugen-ui.css, written by yugen css */
@import "yugen-ui/theme-core.css";
/* One line per component folder your code imports. */
@import "yugen-ui/sources/Button.css";
@import "yugen-ui/sources/DataGrid.css";
@import "yugen-ui/sources/Dialog.css";
@import "yugen-ui/sources/YugenProvider.css";| Template | Component folders | theme.css | yugen css |
|---|---|---|---|
| Starter | 5 | 26.7 kB | 13.2 kB |
| Next.js Starter | 7 | 26.2 kB | 11.8 kB |
| Landing | 16 | 26.9 kB | 16.1 kB |
| Admin | 34 | 26.4 kB | 18.1 kB |
| SaaS | 35 | 26.3 kB | 20.0 kB |
- CSS gzip, including the app's own classes, measured with
scripts/measure-css.mjs. - Run the command again after you import another component (in package mode
yugen adddoes it), or put it in yourpredev/prebuildscripts.yugen css --checkexits with 1 when the file is out of date, andyugen doctorwarns about it. See the CLI page. - Each file already includes the components that component uses inside (
SplitButton.csshas Menu and Button), so list only the components you import. - The file is named after the component's folder:
NavItemis insources/Nav.css,toastinsources/Toast.css. The export → file table is inyugen-ui/sources/index.json. You can also write these lines by hand. - A missing file leaves that component unstyled.
import * as,export *andimport("yugen-ui")can reach any component, so the generated file falls back to theme.css when it finds one. - Without Tailwind:
yugen css --precompilecompiles the same list into plain CSS that replacesstyles.css. - Not needed when you copy component source (shadcn registry,
@yugen-ui/cli add): Tailwind already scans the app's folders.styles.css(no Tailwind) always contains every component.
The media object pattern
Avatar or icon, a text block and actions: a layout found everywhere. Three classes decide whether it breaks.
Emma Collins
Product designer · International Payments and Reconciliation Platform team
Daniel Park
Frontend engineer
<div className="flex items-center gap-3">
<Avatar name="Emma Collins" color="colorful" size={40} />
<div className="min-w-0 flex-1">
<p className="truncate text-body1 font-semibold text-foreground-1">Emma Collins</p>
<p className="truncate text-caption1 text-foreground-3">Product designer · International Payments…</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button size="small">Message</Button>
<Button size="small" appearance="subtle" icon={<MoreHorizontal20Regular />} aria-label="More actions" />
</div>
</div>min-w-0 flex-1on the text block: a flex item doesn't shrink below its content by default, so withoutmin-w-0,truncatehas no effect and the row overflows.shrink-0on the avatar and the action group keeps their size when the text is long.gap-*instead of margins, which stays correct in RTL. CardHeader, ListItem, TableCellLayout and Persona already package this pattern.
- Merge classes with cn() and put the user's className last.
- Use token utilities (text-foreground-2, p-3, rounded-medium, shadow-16).
- Style states with the data-[state=…] attributes Radix already sets.
- Don't use arbitrary values (#hex, 14px, 120ms) or the Tailwind palette (bg-blue-500).
- Don't use dark:. The theme lives in the tokens.
- Don't use ml/mr/pl/pr/left/right; use ms/me/ps/pe/start/end.