Yugen UI

Theming

Theme, brand và hướng chữ là attribute trên DOM; YugenProvider gắn chúng cho cả ứng dụng hoặc cho từng nhánh giao diện.

data-theme & data-brand

Toàn bộ màu đọc từ CSS custom property. Hai attribute quyết định bộ giá trị: data-theme chọn bảng neutral / semantic, data-brand chọn ramp thương hiệu.

AttributeGiá trịÝ nghĩa
data-themelightMặc định (khai báo ở :root), tương đương webLightTheme.
data-themedarkTương đương webDarkTheme; nền tối, brand dùng bậc sáng hơn (Background = brand70, Foreground1 = brand100).
data-themehigh-contrastChế độ tương phản cao (tương đương teamsHighContrastTheme): nền đen, chữ trắng, nhấn cyan, link vàng, disabled xanh lá; bóng đổ thành viền trắng 1px. Đã khai báo sẵn trong tokens.css — chọn “Tương phản cao” ở menu Theme trên cùng để thử.
data-brandsakuraMặc định — hồng anh đào 桜, Brand80 đạt 5.16:1 với chữ trắng.
data-brandwebCommunication Blue, brand gốc của Fluent 2.
data-brandteamsTím chàm “Dusk”.
data-brandtên-riêngBất kỳ ramp nào bạn khai báo bằng [data-brand="tên-riêng"] (xem cuối trang).
<!-- Không cần JavaScript: attribute trên <html> là đủ -->
<html lang="vi" data-theme="dark" data-brand="web" dir="ltr">

YugenProvider

Tương đương FluentProvider: áp theme, brand và hướng chữ cho một cây component, đồng thời gắn DirectionProvider của Radix, TooltipProvider và (tuỳ chọn) Toaster.

Prop · KiểuMặc địnhMô tả
theme"light" | "dark" | "high-contrast"Mặc định: "light"Ghi vào data-theme.
brand"sakura" | "web" | "teams" | stringMặc định: "sakura"Ghi vào data-brand; nhận tên brand tuỳ biến.
dir"ltr" | "rtl"Mặc định: "ltr"Ghi dir lên thẻ bao và cấp hướng chữ cho Radix (bàn phím, Slider, Menu con…).
applyToDocumentbooleanMặc định: falsetrue: ghi data-theme / data-brand / dir lên <html> — dùng cho provider gốc để portal cũng đúng theme. false: chỉ áp cho thẻ div bao ngoài (theme lồng), div tự có bg-background-1 text-foreground-1.
withTooltipProviderbooleanMặc định: trueBọc TooltipProvider (delay 300ms) cho Tooltip bên trong.
withToasterbooleanMặc định: falseDeprecated in 1.0.0. Gắn <Toaster /> làm con của provider gốc để toast() hoạt động, chọn vị trí và limit rõ ràng; mặc định bottom-end (bottom-start khi RTL).
messagesYugenMessagesInputMặc định: —Đổi chuỗi có sẵn của component (mặc định tiếng Anh) theo namespace, vd. messagesVi cho tiếng Việt. Provider lồng kế thừa rồi ghi đè tiếp. Xem Ngôn ngữ giao diện.
localestringMặc định: "en-US"Locale Intl cho ngày, giờ và số trong component. Provider lồng kế thừa provider ngoài.
defaultsYugenDefaultsInputMặc định: —Props mặc định theo component, vd. { Button: { shape: "circular" } }. Prop trên instance thắng. Provider lồng gộp theo từng component. Xem Props mặc định & mật độ bên dưới.
density"compact" | "default" | "comfortable"Mặc định: "default"compact: control mặc định size small, spacing nhân 0.9375. comfortable: spacing nhân 1.125. Ghi vào data-density (và lên <html> khi applyToDocument). Provider lồng kế thừa.
scrollLock"hide-scrollbar" | "keep-scrollbar"Mặc định: "hide-scrollbar"Giữ hay ẩn thanh cuộn của trang khi Menu, ContextMenu, Dropdown mở. Chỉ có tác dụng với applyToDocument (ghi data-yugen-scroll-lock lên <html>). Bản 1.0 đổi mặc định sang keep-scrollbar. Xem Khoá cuộn.
className, …div propsHTMLAttributes<HTMLDivElement>Mặc định: —Truyền xuống thẻ div bao ngoài (ref được forward).
// Provider gốc — một lần duy nhất
<YugenProvider applyToDocument theme="light" brand="sakura" dir="ltr">
  <Toaster />
  <App />
</YugenProvider>

Props mặc định & mật độ

defaults đặt prop cho mọi instance của một component trong một vùng giao diện. density làm vùng đó gọn hơn hoặc thoáng hơn. Prop đặt trên instance luôn thắng.

<YugenProvider
  applyToDocument
  density="compact"
  defaults={{
    Button: { shape: "circular" },
    Input: { appearance: "filled-darker" },
  }}
>
  <App />
</YugenProvider>

// Prop đặt trên instance vẫn thắng
<Button size="medium">Xuất file</Button>
  • Thứ tự: prop trên instance, rồi defaults của provider gần nhất (provider lồng gộp theo từng component), rồi density, cuối cùng là mặc định của chính component.
  • density="compact" cho nút, ô nhập, picker, tab, toolbar, bảng, Tree và Nav mặc định size small, và nhân spacing với 0.9375. "comfortable" nhân spacing với 1.125, giữ nguyên size. Spacing đổi qua data-density trong CSS (--spacing và token spacing, cùng hệ số với Theme Designer), nên HTML prerender đã đúng trước khi hydrate.
  • Ở mật độ gọn, control size small cao 22.5px. Chừa khoảng cách giữa các control đứng sát nhau (WCAG 2.5.8).
  • Component có prop kiểu dáng (size, appearance, shape, material) đọc mặc định của mình. Phần con như TabsTrigger hay ToolbarButton theo component cha. Mặc định cũng tới component dùng bên trong component khác, như nút đóng của Dialog, nên ưu tiên size, shape và material hơn appearance.
  • defaults có kiểu theo từng component. Object viết thẳng trong JSX được so theo giá trị, nên provider render lại với cùng defaults không làm cả cây render lại.
  • Yugen Labs: defaults={glassNavigationDefaults} cho mọi component điều hướng có glass material="glass". Xem Glass.
  • Chế độ mã nguồn (registry shadcn, yugen add): có thể sửa luôn defaultVariants trong file đã chép.

Hook useYugen()

Trả về { theme, brand, dir, locale, density } của YugenProvider gần nhất — dùng khi component cần đổi nội dung (không phải màu) theo theme hoặc hướng chữ.

import * as React from "react";
import { WeatherMoon20Regular, WeatherSunny20Regular } from "@fluentui/react-icons";
import { Button, Toaster, YugenProvider, useYugen, type YugenTheme } from "@/components/ui";

export default function App() {
  const [theme, setTheme] = React.useState<YugenTheme>("light");
  return (
    <YugenProvider applyToDocument theme={theme}>
      <Toaster />
      <ThemeToggle onToggle={() => setTheme((t) => (t === "light" ? "dark" : "light"))} />
    </YugenProvider>
  );
}

function ThemeToggle({ onToggle }: { onToggle: () => void }) {
  const { theme } = useYugen();
  const dark = theme === "dark";
  return (
    <Button
      appearance="subtle"
      icon={dark ? <WeatherSunny20Regular /> : <WeatherMoon20Regular />}
      aria-label={dark ? "Chuyển sang theme sáng" : "Chuyển sang theme tối"}
      onClick={onToggle}
    />
  );
}

Đừng dùng useYugen() để chọn màu — màu đã tự đổi qua token. Hãy dùng nó cho nội dung: icon mặt trời / mặt trăng, ảnh minh hoạ sáng / tối, hướng mũi tên. Bề mặt trong portal đã tự theo provider lồng (xem trang Định vị).

import { useYugenLocale, useYugenTheme } from "@/components/ui";

const { theme, brand, dir } = useYugenTheme(); // render lại khi theme, brand hoặc dir đổi
const locale = useYugenLocale(); // render lại khi locale đổi

useYugen() render lại khi theme hoặc phần cấu hình locale đổi. Component chỉ cần một phần thì dùng useYugenTheme() ({ theme, brand, dir }) hoặc useYugenLocale() (chuỗi locale). Component của Yugen cũng làm vậy, nên đổi theme chỉ render lại phần đọc theme, như chart và CodeBlock.

Theme lồng nhau

Đặt data-theme / data-brand trên bất kỳ phần tử nào để đổi theme cho cả nhánh con — YugenProvider không applyToDocument làm đúng việc đó.

useYugen() → theme: light · brand: sakura · dir: ltr

3

useYugen() → theme: dark · brand: web · dir: ltr

3

useYugen() → theme: light · brand: teams · dir: ltr

3
import { YugenProvider } from "@/components/ui";

<div className="grid gap-4 md:grid-cols-3">
  <YugenProvider theme="light" brand="sakura" className="rounded-xlarge border border-stroke-2 p-4">
    <Sample />
  </YugenProvider>
  <YugenProvider theme="dark" brand="web" className="rounded-xlarge border border-stroke-2 p-4">
    <Sample />
  </YugenProvider>
  <YugenProvider theme="light" brand="teams" className="rounded-xlarge border border-stroke-2 p-4">
    <Sample />
  </YugenProvider>
</div>
  • Khi lồng một brand bên trong theme khác với <html>, đặt cả hai attribute trên cùng một phần tử: alias brand được tính lại theo cặp [data-brand][data-theme]. YugenProvider luôn ghi cả hai.
  • Phần tử mang attribute nên tự đặt nền (bg-background-1, bg-background-2…) — nếu không, nó trong suốt và lộ nền của theme cha.
  • Dialog, Menu, Popover, Tooltip render qua portal vào <body> nhưng vẫn tự nhận theme / brand / hướng chữ của YugenProvider lồng gần nhất (qua usePortalThemeProps()). Chi tiết ở trang Định vị.

Ba lớp token

Global → Alias → Utility. Mỗi lớp chỉ được đọc bởi lớp ngay sau nó.

LớpVí dụAi được dùng
Global--grey-14, --brand-80, --red-shade-10Chỉ tokens.css (và minh hoạ trang trí, trang bảng màu).
Alias--colorNeutralForeground1, --colorBrandBackground, --spacingM, --shadow16index.css (map sang Tailwind) và CSS tuỳ biến khi thật cần.
Utilitytext-foreground-1, bg-brand-background, p-3, shadow-16Mọi file .tsx.
/* 1. GLOBAL (tokens.css) — giá trị thô, không gắn vai trò */
:root { --grey-14: #242424; --white: #ffffff; }
[data-brand="web"] { --brand-70: #115ea3; --brand-80: #0f6cbd; /* … 10 → 160 */ }

/* 2. ALIAS (tokens.css) — vai trò, tên trùng Fluent UI v9, đổi theo theme */
[data-theme="light"] { --colorNeutralForeground1: var(--grey-14); }
[data-theme="light"], [data-brand] { --colorBrandBackground: var(--brand-80); }
[data-theme="dark"] { --colorBrandBackground: var(--brand-70); }

/* 3. UTILITY (index.css) — cầu nối sang Tailwind */
@theme inline {
  --color-foreground-1: var(--colorNeutralForeground1);   /* text-foreground-1 */
  --color-brand-background: var(--colorBrandBackground);  /* bg-brand-background */
}

Tên alias trùng 1-1 với token Fluent UI v9, nên tài liệu Fluent tra được trực tiếp: tokens.colorNeutralStroke1 chính là var(--colorNeutralStroke1) và utility border-stroke-1.

Tạo brand riêng

Brand là một ramp 16 bậc --brand-10 … --brand-160. Alias (nền nút, link, focus, tint chọn) tự lấy đúng bậc cho light và dark.

  1. Mở Theme Designer, nhập màu chủ đạo và tên brand. Trang sinh ramp bằng cách giữ đường cong lightness / chroma OKLCH của Communication Blue và đổi hue, xem trước trên component thật, kiểm tra tương phản.
  2. Ở mục Xuất token, chép CSS (hoặc tải file) và dán khối [data-brand="tên"] vào file CSS chính của app, ngay sau @import "yugen-ui/theme.css". Dự án đã chép mã nguồn thì dán vào src/styles/tokens.css, sau các brand có sẵn.
  3. Dùng brand="tên" trên YugenProvider (kiểu YugenBrand nhận chuỗi bất kỳ) hoặc data-brand="tên" trên <html>.
/* src/styles/tokens.css — dán sau các block brand có sẵn (ví dụ minh hoạ; dùng Theme Designer để sinh ramp chính xác) */
[data-brand="matcha"] {
  --brand-10: #0a1a10;
  --brand-20: #0f2818;
  --brand-30: #13351f;
  --brand-40: #174327;
  --brand-50: #1b522f;
  --brand-60: #1f6137;
  --brand-70: #236f3f;
  --brand-80: #277d47; /* màu chủ đạo — phải ≥ 4.5:1 trên nền trắng */
  --brand-90: #2f9455;
  --brand-100: #3fac66;
  --brand-110: #5bbb7d;
  --brand-120: #74c690;
  --brand-130: #93d4a8;
  --brand-140: #b1e1c0;
  --brand-150: #cdeed6;
  --brand-160: #e9f7ed;
}
// Toàn ứng dụng
<YugenProvider applyToDocument theme="light" brand="matcha">…</YugenProvider>

// Hoặc chỉ một vùng (ví dụ trang marketing của sản phẩm con)
<YugenProvider theme="light" brand="matcha" className="rounded-xlarge p-5">…</YugenProvider>
Nên
  • Giữ Brand80 ≥ 4.5:1 với chữ trắng — đó là nền của nút primary.
  • Chọn hue cách xa đỏ danger để nút xoá không bị nhầm với nút chính.
  • Kiểm tra cả light và dark ở trang Màu sắc › Khả năng tiếp cận.
Không nên
  • Không tự pha từng bậc bằng tay — đường cong lightness lệch sẽ làm hover / pressed sai.
  • Không trộn hai brand trong cùng một vùng giao diện.
  • Không phủ brand lên diện tích lớn (header, sidebar, nền trang).