Yugen UI

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.

New
<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 classesclassName passed incn() returns
text-body1 text-foreground-2text-foreground-1text-body1 text-foreground-1
px-3 py-2px-5py-2 px-5
rounded-medium shadow-4rounded-circular shadow-16rounded-circular shadow-16
bg-background-1 hover:bg-background-1-hoverbg-brand-background-2hover:bg-background-1-hover bg-brand-background-2
Always import 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.

ForUseDon't use
Secondary text colortext-foreground-2text-gray-600, text-[#…]
Primary button backgroundbg-brand-backgroundbg-pink-600, bg-[#…]
UI font sizetext-body1text-sm, text-[14px]
Control radiusrounded-mediumrounded-md, rounded-[4px]
Menu shadowshadow-16shadow-lg
Hover durationduration-fasterduration-100
Dark themedata-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.

p-0.52pxOld alias: p-XXS
p-14pxOld alias: p-XS
p-1.56pxOld alias: p-SNudge
p-28pxOld alias: p-S
p-2.510pxOld alias: p-MNudge
p-312pxOld alias: p-M
p-416pxOld alias: p-L
p-520pxOld alias: p-XL
p-624pxOld alias: p-XXL
p-832pxOld alias: p-XXXL

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>
AttributeSet byExample variant
data-state="open | closed"Menu, Popover, Dialog, Tooltip, Accordion, Collapsible trigger & contentdata-[state=open]:bg-subtle-pressed
data-state="checked | unchecked"Checkbox, Switch, Radio, MenuItemCheckboxdata-[state=checked]:bg-brand-background
data-state="active"TabsTrigger, TabsContentdata-[state=active]:font-semibold
data-highlightedMenu and Dropdown items (keyboard / mouse)data-highlighted:bg-background-1-hover
data-disabledDisabled items, Slider, Dropdown triggerdata-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().

Shared classes

When you write a new control, build it from the shared classes to keep the Fluent look instead of copying class strings.

ClassExported fromUsed for
fieldControlClassFieldInput shell: stroke-1 border, stroke-accessible bottom edge, 2px brand bar that grows from the center on focus; data-invalid / data-disabled.
fieldAppearanceClass[appearance]SelectSame as above, per appearance: outline / underline / filled-darker / filled-lighter.
popoverSurfaceClassPopoverFloating surface: z-50, bg-background-1, shadow-16, open / close animation.
menuPopoverClassMenuMenu surface (Menu, ContextMenu, Menubar).
menuItemClassMenu32px menu item, highlighted through data-highlighted.
listboxOptionClassDropdownListbox option (Dropdown, Combobox, TagPicker).
cmdkOptionClassComboboxOption highlighted through data-selected=true (Combobox, TagPicker, TimePicker).

Example: a custom read-only field that still has the stroke-accessible bottom edge and the brand bar on focus (click it or Tab to it):

YUGEN-2026-SAKURA
import { fieldControlClass } from "@/components/ui";
import { cn } from "@/lib/cn";

<div
  tabIndex={0}
  role="textbox"
  aria-readonly
  aria-label="Referral code"
  className={cn(fieldControlClass, "flex h-8 w-64 items-center px-2.5 font-mono text-body1")}
>
  YUGEN-2026-SAKURA
</div>

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 css

It 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";
TemplateComponent folderstheme.cssyugen css
Starter526.7 kB13.2 kB
Next.js Starter726.2 kB11.8 kB
Landing1626.9 kB16.1 kB
Admin3426.4 kB18.1 kB
SaaS3526.3 kB20.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 add does it), or put it in your predev / prebuild scripts. yugen css --check exits with 1 when the file is out of date, and yugen doctor warns about it. See the CLI page.
  • Each file already includes the components that component uses inside (SplitButton.css has Menu and Button), so list only the components you import.
  • The file is named after the component's folder: NavItem is in sources/Nav.css, toast in sources/Toast.css. The export → file table is in yugen-ui/sources/index.json. You can also write these lines by hand.
  • A missing file leaves that component unstyled. import * as, export * and import("yugen-ui") can reach any component, so the generated file falls back to theme.css when it finds one.
  • theme-core.css has the Sakura brand and the tokens every component needs. The web and teams brands are in yugen-ui/tokens/brands.css: yugen css adds it when your code sets brand="web" or brand="teams". Chart tokens come with sources/Chart.css.
  • Without Tailwind: yugen css --precompile compiles the same list into plain CSS that replaces styles.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.
  • Embedding Yugen in a page whose CSS you don't own (a CMS, a widget, a micro-frontend): import yugen-ui/components.css instead of styles.css. It has no global reset and no styles for html, body or ::selection; Tailwind's reset only applies inside YugenProvider.

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-1 on the text block: a flex item doesn't shrink below its content by default, so without min-w-0, truncate has no effect and the row overflows.
  • shrink-0 on 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.
Do
  • 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
  • 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.