jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Tailwind, Radix & shadcn/ui · Part 3 — Design Tokens & Theming with @theme

The heart of Tailwind v4: define your own colors, fonts, spacing and radius as design tokens with @theme — each token becomes a utility automatically. Plus the v4 way to do dark mode. With a live token studio.

In v3 you configured Tailwind in a JavaScript file {Ở v3 bạn cấu hình Tailwind trong một file JavaScript}. In v4 the configuration lives in your CSS, expressed as design tokens with the @theme directive {Ở v4 cấu hình nằm trong CSS của bạn, biểu diễn dưới dạng design token với directive @theme}. This is the single most important v4 concept, and the bridge to shadcn/ui later {Đây là khái niệm v4 quan trọng nhất, và là cầu nối tới shadcn/ui sau này}.


1. What a design token is {Design token là gì}

A design token is a named design decision — “the brand color is #6366f1”, “the card radius is 12px” — stored as a variable so it has one source of truth {Một design token là một quyết định thiết kế có tên — “màu brand là #6366f1”, “bo card là 12px” — lưu dưới dạng biến để có một nguồn sự thật duy nhất}. Change the token, and everything using it updates {Đổi token, mọi thứ dùng nó đều cập nhật}.

Tailwind v4’s superpower {Siêu năng lực của Tailwind v4}: every token you declare in @theme automatically generates matching utilities {mọi token bạn khai trong @theme tự động sinh ra utility tương ứng}.


2. The @theme directive {Directive @theme}

/* src/index.css */
@import "tailwindcss";

@theme {
  --color-brand: #6366f1;
  --color-brand-foreground: #ffffff;
  --radius-card: 12px;
  --font-display: "Cal Sans", sans-serif;
  --spacing-gutter: 1.25rem;
}

From those five lines you now have utilities you can use anywhere {Từ năm dòng đó bạn đã có utility dùng được khắp nơi}:

<button class="bg-brand text-brand-foreground rounded-card font-display p-gutter">
  Upgrade
</button>

The naming is a convention that maps token namespace → utility family {Cách đặt tên là một quy ước ánh xạ namespace token → nhóm utility}:

Token prefixGeneratesExample
--color-*bg-*, text-*, border-*, fill-*--color-brandbg-brand
--radius-*rounded-*--radius-cardrounded-card
--font-*font-*--font-displayfont-display
--spacing-*p-*, m-*, gap-*--spacing-gutterp-gutter
--text-*text-* (font-size)--text-basetext-base
--shadow-*shadow-*--shadow-cardshadow-card

Change one token and your whole UI follows — see it live {Đổi một token và toàn bộ UI đi theo — xem trực tiếp}:

Tokens are real CSS variables. They’re emitted into :root, so you can also read them at runtime (var(--color-brand)) or override them in a scope — which is exactly how theming and dark mode work {Token là biến CSS thật. Chúng được phát vào :root, nên bạn cũng đọc được lúc runtime (var(--color-brand)) hoặc override trong một scope — đúng cách mà theming và dark mode hoạt động}.


3. Extending vs replacing the defaults {Mở rộng vs thay thế mặc định}

By default @theme adds to Tailwind’s built-in tokens {Mặc định @theme thêm vào token có sẵn của Tailwind}. Your --color-brand joins --color-slate-500 and friends {--color-brand của bạn nhập hội cùng --color-slate-500}. If you want to wipe a namespace and start clean (e.g., a bespoke palette), reset it first {Muốn xóa sạch một namespace và bắt đầu mới (vd bảng màu riêng), reset trước}:

@theme {
  /* Drop ALL default colors, keep only what you define */
  --color-*: initial;
  --color-bg: #0a0a0a;
  --color-fg: #e5e5e5;
  --color-brand: #c8ff00;
}

This is great for a tightly controlled design system where you don’t want 22 default palettes tempting people off-system {Rất hợp với design system kiểm soát chặt, không muốn 22 bảng màu mặc định cám dỗ người ta đi chệch hệ thống}.


4. Dark mode the v4 way {Dark mode kiểu v4}

Tailwind ships a dark: variant {Tailwind có sẵn variant dark:}. By default it follows the OS via prefers-color-scheme {Mặc định nó theo OS qua prefers-color-scheme}:

<div class="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">…</div>

But most real apps want a manual toggle. Override the variant to key off a .dark class on a parent {Nhưng app thật thường muốn nút bật tay. Override variant để dựa vào class .dark trên cha}:

/* src/index.css */
@import "tailwindcss";

/* Make dark: respond to a .dark class instead of the OS */
@custom-variant dark (&:where(.dark, .dark *));

Then toggle the class on <html> from JS and persist the choice {Rồi bật class trên <html> từ JS và lưu lựa chọn}:

// Toggle + persist (run before paint to avoid a flash)
const root = document.documentElement;
const isDark = localStorage.theme === 'dark';
root.classList.toggle('dark', isDark);

function toggleTheme() {
  const next = !root.classList.contains('dark');
  root.classList.toggle('dark', next);
  localStorage.theme = next ? 'dark' : 'light';
}

The token-driven approach scales best: define semantic color tokens, then redefine them under .dark {Cách token-driven scale tốt nhất: định nghĩa token màu ngữ nghĩa, rồi định nghĩa lại dưới .dark}. Components reference the semantic token and never branch on theme {Component tham chiếu token ngữ nghĩa, không bao giờ rẽ nhánh theo theme}:

@theme {
  --color-bg: #ffffff;
  --color-fg: #0f172a;
}

.dark {
  --color-bg: #0b1120;
  --color-fg: #e2e8f0;
}
<!-- one class, correct in both themes -->
<div class="bg-bg text-fg">…</div>

This semantic-token pattern is exactly what shadcn/ui uses (background, foreground, primary, muted…) — Part 10 {Mẫu token ngữ nghĩa này chính xác là thứ shadcn/ui dùng — Phần 10}.


5. Arbitrary values — the escape hatch {Giá trị tùy ý — cửa thoát hiểm}

When a one-off value isn’t in your scale, use square brackets — but treat it as a smell, not a default {Khi một giá trị lẻ không có trong thang, dùng ngoặc vuông — nhưng coi đó là mùi code, không phải mặc định}:

<div class="top-[117px] bg-[#1da1f2] w-[42ch]">…</div>

If you reach for the same arbitrary value twice, promote it to a token in @theme instead {Nếu bạn dùng cùng một giá trị tùy ý hai lần, nâng nó thành token trong @theme}. That’s the difference between a codebase that drifts and one that stays a system {Đó là khác biệt giữa codebase trôi dạt và một codebase giữ được tính hệ thống}.


6. Exercises {Bài tập}

1. Define a --color-brand token of your choice and a --radius-card of 16px, then build a button using bg-brand and rounded-card {Định nghĩa token --color-brand tùy ý và --radius-card16px, rồi dựng nút dùng bg-brandrounded-card}.

Solution {Lời giải}
@theme {
  --color-brand: #14b8a6;
  --radius-card: 16px;
}
<button class="bg-brand rounded-card px-4 py-2 text-white">Go</button>

2. Set up a manual dark mode toggle (class-based) and make a card readable in both themes using semantic bg-bg/text-fg tokens {Thiết lập dark mode bật tay (theo class) và làm một card đọc được ở cả hai theme bằng token ngữ nghĩa bg-bg/text-fg}.

Solution {Lời giải}
@custom-variant dark (&:where(.dark, .dark *));
@theme { --color-bg: #fff; --color-fg: #0f172a; }
.dark { --color-bg: #0b1120; --color-fg: #e2e8f0; }
<div class="bg-bg text-fg p-6 rounded-card">Readable in both themes</div>

3. You catch yourself writing mt-[18px] in three files. What’s the fix? {Bạn thấy mình viết mt-[18px] ở ba file. Sửa thế nào?}

Solution {Lời giải}

Promote it to a token: --spacing-section: 18px; in @theme, then use mt-section {Nâng thành token: --spacing-section: 18px; trong @theme, rồi dùng mt-section}.

Stretch {Nâng cao}: in the token studio above, find a brand color + radius + base size combo you like, then write the exact @theme block that would reproduce it {trong token studio, chọn bộ màu brand + radius + cỡ chữ bạn thích, rồi viết đúng khối @theme tái tạo nó}.


Key takeaways {Điểm chính}

  • v4 configuration is CSS-first: @theme holds your design tokens {Cấu hình v4 là CSS-first: @theme chứa design token}.
  • Every token auto-generates utilities following the namespace convention (--color-*bg-*/text-*, etc.) {Mỗi token tự sinh utility theo quy ước namespace}.
  • Tokens are real CSS variables → override them in .dark for clean, token-driven theming {Token là biến CSS thật → override trong .dark để theming gọn, token-driven}.
  • Arbitrary values […] are an escape hatch; repeated use means it should be a token {Giá trị tùy ý […] là cửa thoát; lặp lại nghĩa là nên thành token}.

Next up {Tiếp theo}

Part 4 — Variants, states & composition: hover:, focus:, disabled:, the powerful group-* and peer-* patterns, data-[state=open]: styling (the key to Radix/shadcn), and how variants stack {Phần 4 — Variant, trạng thái & kết hợp: hover:, focus:, disabled:, mẫu mạnh mẽ group-*peer-*, style theo data-[state=open]: (chìa khóa cho Radix/shadcn), và cách variant chồng lên nhau}.