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.
<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 component | className truyền vào | cn() trả về |
|---|---|---|
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 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ần | Dùng | Không dùng |
|---|---|---|
| Màu chữ phụ | text-foreground-2 | text-gray-600, text-[#…] |
| Nền nút chính | bg-brand-background | bg-pink-600, bg-[#…] |
| Cỡ chữ UI | text-body1 | text-sm, text-[14px] |
| Bo góc control | rounded-medium | rounded-md, rounded-[4px] |
| Bóng của Menu | shadow-16 | shadow-lg |
| Thời gian hover | duration-faster | duration-100 |
| Theme tối | data-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.
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ởi | Ví dụ 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 | Item của Menu, Dropdown (bàn phím / chuột) | data-highlighted:bg-background-1-hover |
data-disabled | Item bị tắt, Slider, Dropdown trigger | data-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().
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 cssLệ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";| Template | Thư mục component | 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, 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 addtự làm), hoặc đặt nó trong scriptpredev/prebuild.yugen css --checkthoát với mã 1 khi file đã cũ,yugen doctorcũng cảnh báo. Xem trang CLI. - Mỗi file đã gồm các component mà component đó dùng bên trong (
SplitButton.csscó 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 trongyugen-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 *hayimport("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 --precompilebiên dịch cùng danh sách thành CSS thường để thaystyles.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-1trên khối chữ: flex item mặc định không co nhỏ hơn nội dung, thiếumin-w-0thìtruncatekhông có tác dụng và hàng bị tràn.shrink-0trê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.
- 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 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.