Yugen UI

Tuỳ biến style

Mọi component nhận className và gộp bằng cn(); bạn tuỳ biến bằng utility map từ token — không bao giờ bằng giá trị cứng.

className & cn()

className của bạn luôn được đặt cuối cùng trong cn(...) của component, nên nó thắng mọi class mặc định cùng nhóm.

Mới
<Button className="rounded-circular px-5">Bo tròn</Button>
<Button appearance="primary" className="min-w-40">Rộng hơn</Button>
<Badge className="tracking-wide uppercase">Mới</Badge>

cn() trong src/lib/cn.ts là clsx + tailwind-merge đã được dạy scale của Yugen. Thiếu bước này, tailwind-merge gốc coi text-body1 là một màu chữ và xoá nó khi gặp text-foreground-1 — chữ sẽ rơi về cỡ mặc định. Kết quả thật của cn() trên trang này:

Class của componentclassName truyền vàocn() trả về
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
Luôn import cn từ @/lib/cn; không dùng twMerge trực tiếp. Nếu thêm token mới (cỡ chữ, shadow, duration…), nhớ khai báo vào extendTailwindMerge trong cn.ts.

Chỉ dùng utility từ token

Palette, bo góc, shadow, cỡ chữ và easing mặc định của Tailwind đã bị tắt trong index.css — chỉ còn utility map từ token Fluent.

CầnDùngKhông dùng
Màu chữ phụtext-foreground-2text-gray-600, text-[#…]
Nền nút chínhbg-brand-backgroundbg-pink-600, bg-[#…]
Cỡ chữ UItext-body1text-sm, text-[14px]
Bo góc controlrounded-mediumrounded-md, rounded-[4px]
Bóng của Menushadow-16shadow-lg
Thời gian hoverduration-fasterduration-100
Theme tốidata-theme="dark"dark:bg-…

Spacing

Spacing dùng thang số của Tailwind: p-3, gap-2, mt-6. Mười bậc dưới đây trùng ramp spacing của Fluent, nên khi chuyển từ Fluent UI chỉ cần đổi tên: spacingHorizontalM → px-3. Padding, margin và gap nên nằm trong mười bậc này; kích thước phần tử (h-8, size-5, w-64) dùng các số còn lại.

Alias viết hoa cũ (p-M, gap-SNudge) vẫn chạy nhưng đã deprecated từ 0.10 và sẽ bị bỏ ở 1.0. Đổi theo cột bên phải của bảng.

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

Ghi đè & tạo variant

Ghi đè một lần thì dùng className; lặp lại nhiều nơi thì đóng gói thành variant bằng class-variance-authority (cva).

Vì mã nguồn nằm trong dự án, cách “chuẩn shadcn” là sửa thẳng bảng variants trong Button.tsx. Khi muốn giữ file gốc nguyên vẹn để dễ cập nhật, hãy bọc component và thêm variant bằng 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 />}>Đã gắn sao</PillButton>

Các component cũng export hàm variant để áp style lên phần tử khác: buttonVariants, linkVariants, imageVariants, toggleButtonVariants. Ví dụ <a className={buttonVariants({ appearance: "primary" })}> — tuy vậy <Button asChild> thường gọn hơn (xem trang Slot).

Data-attribute & important

Radix ghi trạng thái lên DOM bằng data-attribute; style theo trạng thái bằng variant data-[…] thay vì state React.

<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"
  >
    Sắp xếp
  </Button>
</MenuTrigger>

{/* Button medium đặt [&_svg]:size-5 — muốn icon 16px phải dùng hậu tố ! (Tailwind v4) */}
<Button icon={<Star20Regular className="size-4!" />}>Icon 16px trong nút medium</Button>
AttributeĐặt bởiVí dụ 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-highlightedItem của Menu, Dropdown (bàn phím / chuột)data-highlighted:bg-background-1-hover
data-disabledItem bị tắt, Slider, Dropdown triggerdata-disabled:text-foreground-disabled
data-side="top | bottom…"Bề mặt nổi sau khi định vị (kể cả khi bị lật)data-[side=top]:origin-bottom

Hậu tố ! (cú pháp Tailwind v4, đặt ở cuối class) chỉ dùng khi component cha đặt selector có độ ưu tiên cao hơn như [&_svg]:size-5. Đừng rải ! để thắng className của chính component — className của bạn đã đứng cuối cn().

Class dùng chung

Khi tự viết control mới, ghép từ class dùng chung để giữ đúng đặc trưng Fluent thay vì chép lại chuỗi class.

ClassExport từDùng cho
fieldControlClassFieldVỏ ô nhập: viền stroke-1, đáy stroke-accessible, thanh brand 2px chạy từ giữa khi focus; data-invalid / data-disabled.
fieldAppearanceClass[appearance]SelectNhư trên, theo appearance outline / underline / filled-darker / filled-lighter.
popoverSurfaceClassPopoverBề mặt nổi: z-50, bg-background-1, shadow-16, animation mở / đóng.
menuPopoverClassMenuBề mặt menu (Menu, ContextMenu, Menubar).
menuItemClassMenuItem menu cao 32px, highlight bằng data-highlighted.
listboxOptionClassDropdownOption trong listbox (Dropdown, Combobox, TagPicker).
cmdkOptionClassComboboxOption highlight bằng data-selected=true (Combobox, TagPicker, TimePicker).

Ví dụ: một ô chỉ đọc tự viết nhưng vẫn có đáy stroke-accessible và thanh brand khi focus (bấm vào hoặc Tab tới ô):

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

<div
  tabIndex={0}
  role="textbox"
  aria-readonly
  aria-label="Mã giới thiệu"
  className={cn(fieldControlClass, "flex h-8 w-64 items-center px-2.5 font-mono text-body1")}
>
  YUGEN-2026-SAKURA
</div>

Chỉ CSS của component bạn dùng

theme.css cho Tailwind quét mọi component của gói, nên app nào cũng mang CSS của mọi component. Lệnh css của CLI đọc import trong mã của app và ghi một file chỉ gồm các component đó.

npx @yugen-ui/cli@latest css

Lệnh ghi yugen-ui.css cạnh file CSS chính và thay @import "yugen-ui/theme.css" bằng @import "./yugen-ui.css". yugen init làm sẵn bước này cho dự án mới.

/* src/yugen-ui.css, do yugen css ghi */
@import "yugen-ui/theme-core.css";
/* Một dòng cho mỗi thư mục component mà mã import. */
@import "yugen-ui/sources/Button.css";
@import "yugen-ui/sources/DataGrid.css";
@import "yugen-ui/sources/Dialog.css";
@import "yugen-ui/sources/YugenProvider.css";
TemplateThư mục componenttheme.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, gồm cả class của chính app, đo bằng scripts/measure-css.mjs.
  • Import thêm component thì chạy lại lệnh (ở chế độ package, yugen add tự làm), hoặc đặt nó trong script predev / prebuild. yugen css --check thoát với mã 1 khi file đã cũ, yugen doctor cũng cảnh báo. Xem trang CLI.
  • Mỗi file đã gồm các component mà component đó dùng bên trong (SplitButton.css có Menu và Button), nên chỉ cần liệt kê component bạn import.
  • Tên file là thư mục chứa component: NavItem ở sources/Nav.css, toast ở sources/Toast.css. Bảng export → file nằm trong yugen-ui/sources/index.json. Có thể tự viết các dòng này thay cho CLI.
  • Thiếu một file thì component đó không có style. import * as, export * hay import("yugen-ui") có thể dùng mọi component, nên khi gặp chúng file sinh ra quay về theme.css.
  • Không dùng Tailwind: yugen css --precompile biên dịch cùng danh sách thành CSS thường để thay styles.css.
  • Copy mã nguồn component (shadcn registry, @yugen-ui/cli add) thì không cần: Tailwind đã quét thư mục của app. styles.css (không dùng Tailwind) luôn chứa mọi component.

Công thức media object

Avatar / icon + khối chữ + thao tác là bố cục gặp ở khắp nơi. Ba class quyết định nó có vỡ hay không.

Trần Minh Anh

Thiết kế sản phẩm · Nhóm Nền tảng thanh toán và đối soát quốc tế

Lê Hoàng Phúc

Kỹ sư frontend

<div className="flex items-center gap-3">
  <Avatar name="Trần Minh Anh" color="colorful" size={40} />
  <div className="min-w-0 flex-1">
    <p className="truncate text-body1 font-semibold text-foreground-1">Trần Minh Anh</p>
    <p className="truncate text-caption1 text-foreground-3">Thiết kế sản phẩm · Nhóm Nền tảng…</p>
  </div>
  <div className="flex shrink-0 items-center gap-1">
    <Button size="small">Nhắn tin</Button>
    <Button size="small" appearance="subtle" icon={<MoreHorizontal20Regular />} aria-label="Thêm thao tác" />
  </div>
</div>
  • min-w-0 flex-1 trên khối chữ: flex item mặc định không co nhỏ hơn nội dung, thiếu min-w-0 thì truncate không có tác dụng và hàng bị tràn.
  • shrink-0 trên avatar và nhóm thao tác để chúng giữ nguyên kích thước khi chữ dài.
  • gap-* thay cho margin — tự đúng khi đổi sang RTL. CardHeader, ListItem, TableCellLayout và Persona đã đóng gói sẵn công thức này.
Nên
  • Gộp class bằng cn() và để className của người dùng ở cuối.
  • Dùng utility token (text-foreground-2, p-3, rounded-medium, shadow-16).
  • Style trạng thái bằng data-[state=…] do Radix đặt sẵn.
Không nên
  • Không dùng giá trị tuỳ ý (#hex, 14px, 120ms) hay palette Tailwind (bg-blue-500).
  • Không dùng dark: — theme nằm trong token.
  • Không dùng ml/mr/pl/pr/left/right; dùng ms/me/ps/pe/start/end.