jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Tailwind, Radix & shadcn/ui · Part 14 — Tailwind v4 Cheatsheet & Production Reference

Reference học và làm thật cho Tailwind v4: bản đồ utility, recipe layout/component, @theme design tokens, shadcn semantic tokens, gotchas và checklist debug production.

Một cheatsheet tốt không phải là bảng nhồi thật nhiều class vào một trang. Nó phải giúp bạn trả lời nhanh câu hỏi quan trọng hơn:

“Mình đang muốn UI làm điều gì, vậy nhóm utility nào giải quyết đúng việc đó?”

Đây là bài reference cuối series. Nếu Phần 1-12 là hành trình xây nền và Phần 13 đào sâu variant, bài này là bản đồ làm việc hằng ngày: mở ra khi bạn đang code, tra đúng nhóm, lấy recipe, hiểu vì sao nó đúng, rồi áp dụng vào component thật.

Sau bài này, một người mới không chỉ nhớ flex hay p-4, mà sẽ biết:

  • nhìn một layout và tách nó thành display, flow, spacing, sizing, state;
  • dùng Tailwind như một API của CSS chứ không copy class vô thức;
  • dựng component theo token semantic kiểu shadcn thay vì hard-code màu;
  • debug các lỗi “class không ăn”, “text làm vỡ flex”, “dark mode lệch”, “ring không hiện”;
  • biết lúc nào dùng utility mặc định, lúc nào tạo token @theme, lúc nào dùng arbitrary value.

Cheatsheet tương tác nằm ngay dưới đây. Nó gom 13 nhóm utility và 1 nhóm @theme design tokens. Bài viết này là lớp giải thích và recipe production đi kèm.


1. Cách tư duy đúng: Tailwind là API của CSS

Tailwind không thay CSS. Tailwind đặt một lớp API ngắn, nhất quán lên trên CSS.

<div class="bg-background flex items-center gap-3 rounded-md border p-4">
  ...
</div>

Đọc ngược về CSS:

UtilityCSS / ý nghĩa
flexdisplay: flex
items-centeralign-items: center
gap-3khoảng cách con theo token spacing
rounded-mdbán kính bo góc theo token radius
borderborder mặc định
bg-backgroundmàu nền semantic token
p-4padding từ thang spacing

Điểm quan trọng: utility của Tailwind chia làm hai nhóm lớn.

NhómVí dụTính chất
Static utilityflex, grid, relative, overflow-hidden, object-coverGần như giống nhau ở mọi project
Token-driven utilitybg-primary, p-4, rounded-lg, text-sm, shadow-mdTồn tại và mang giá trị nhờ theme variables

Vì vậy học Tailwind không phải học thuộc từng class. Học đúng là:

  1. hiểu CSS gốc;
  2. biết Tailwind đặt tên CSS đó như thế nào;
  3. biết utility nào lấy giá trị từ token;
  4. biết khi nào nên tạo token mới.

Một senior frontend không hỏi “class nào cho đẹp?”. Họ hỏi:

  • Đây là layout một chiều hay hai chiều?
  • Kích thước này là token thiết kế hay ngoại lệ?
  • Màu này là primitive color hay semantic role?
  • State này đến từ pseudo-class, ARIA, data-state, hay app state?
  • Có đang phá accessibility hoặc responsive không?

Cheatsheet sẽ hữu ích nhất khi bạn dùng nó theo đúng chuỗi câu hỏi đó.


2. Bản đồ toàn bộ nhóm utility

Khi bị bí, đừng cuộn docs từ trên xuống. Hãy xác định loại vấn đề trước.

Bạn đang làm gì?Mở nhómUtility hay gặp
Đổi cách phần tử tham gia layoutLayoutblock, flex, grid, hidden, contents
Đặt overlay, badge, sticky headerLayoutrelative, absolute, fixed, sticky, inset-0, z-50
Chặn tràn, tạo vùng scrollLayout / Sizingoverflow-hidden, overflow-y-auto, min-h-0, h-dvh
Căn hàng ngang, toolbar, navFlexbox & Gridflex, items-center, justify-between, gap-*, shrink-0, min-w-0
Làm card grid, dashboard gridFlexbox & Gridgrid, grid-cols-*, col-span-*, auto-rows-*
Tạo nhịp khoảng cáchSpacingp-*, px-*, py-*, m-*, gap-*, space-y-*
Khóa kích thước icon/avatarSizingsize-*, w-*, h-*, shrink-0
Chống text dài phá layoutSizing / Typographymin-w-0, truncate, line-clamp-*, break-words
Xây hierarchy chữTypographytext-sm, font-medium, leading-relaxed, tracking-tight
Dùng màu tokenBackgrounds / Typography / Bordersbg-background, text-foreground, border-border, ring-ring
Tạo focus/hover stateVariants + Bordershover:*, focus-visible:ring-2, disabled:opacity-50
Style SVG iconSVG / Sizingsize-4, text-muted-foreground, stroke-current, fill-current
Tạo themeDesign Tokens@theme, --color-*, --spacing-*, --radius-*

Một cách nhớ ngắn:

  • Layout quyết định phần tử nằm ở đâu.
  • Spacing quyết định khoảng cách giữa các thứ.
  • Sizing quyết định hộp lớn bao nhiêu.
  • Typography quyết định chữ đọc thế nào.
  • Color/Borders/Effects quyết định bề mặt và độ nổi.
  • Variants quyết định khi nào style đổi.
  • Tokens quyết định hệ thống có nhất quán hay không.

3. Layout: display, position, overflow, z-index

Layout là tầng đầu tiên. Nếu layout sai, mọi class màu sắc bên trên chỉ làm lỗi đẹp hơn.

Display: chọn cơ chế dòng chảy

UtilityDùng khi
blockPhần tử chiếm hết dòng theo flow thường
inline-blockCần nằm trong dòng nhưng vẫn có width/height
flexCăn hàng/cột một chiều
gridChia layout hai chiều
hiddenLoại khỏi layout hoàn toàn
contentsBỏ wrapper box nhưng giữ children trong DOM

Sai lầm phổ biến là dùng flex cho mọi thứ. Flex rất tốt cho hàng/cột một chiều, nhưng nếu bạn cần cả hàng lẫn cột, đặc biệt dashboard, form layout, card matrix, hãy nghĩ tới grid trước.

// Một chiều: toolbar
<div className="flex items-center justify-between gap-3">
  <Logo />
  <Nav />
  <UserMenu />
</div>

// Hai chiều: dashboard cards
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
  {cards.map((card) => (
    <DashboardCard key={card.id} {...card} />
  ))}
</section>

Position: chỉ dùng khi thoát khỏi flow

position không phải công cụ căn layout chính. Nó là công cụ để thoát khỏi flow hoặc neo theo một hộp khác.

UtilityDùng cho
relativeTạo containing block cho absolute child
absoluteBadge, icon overlay, menu nội bộ
fixedModal backdrop, command palette, global toast
stickyHeader bám trong vùng scroll
inset-0Phủ đủ bốn cạnh

Recipe overlay chuẩn:

<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm" />
<div className="fixed left-1/2 top-1/2 z-50 w-[min(92vw,32rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg">
  <DialogContent />
</div>

Vì sao đúng:

  • fixed inset-0 phủ viewport;
  • z-50 đưa layer lên trên app shell;
  • bg-black/50 chỉ làm mờ nền, không làm mờ content;
  • w-[min(92vw,32rem)] là arbitrary value hợp lý vì đây là công thức layout riêng;
  • translate dùng để căn giữa theo chính kích thước của hộp.

Overflow: làm chủ vùng scroll

Trong app thật, lỗi scroll thường đến từ việc không xác định rõ ai được cuộn.

<div className="grid h-dvh grid-rows-[3.5rem_1fr] overflow-hidden">
  <header className="border-b px-4">...</header>
  <main className="min-h-0 overflow-y-auto p-4">...</main>
</div>

Hai class quan trọng:

  • overflow-hidden ở shell ngoài: không cho toàn page cuộn lung tung.
  • min-h-0 ở grid/flex child: cho phép child co thấp hơn nội dung để overflow-y-auto hoạt động.

min-h-0 giống min-w-0 ở flex row: nhỏ nhưng cứu rất nhiều layout.

Z-index: ít tầng, đặt tên trong đầu

Không nên rải z-[9999]. Hãy có thang tư duy:

LayerUtility thường dùng
Nội dung thườngz-0 hoặc không đặt
Sticky header / sidebarz-10, z-20
Popover / dropdownz-40
Modal / command palette / toastz-50

Nếu z-50 vẫn không nổi, lỗi thường không phải thiếu số. Có thể bạn đang ở trong stacking context mới tạo bởi transform, opacity, filter, isolation, hoặc position + z-index ở cha.


4. Flexbox: một chiều, nhưng phải hiểu shrink

Flexbox giải quyết layout một chiều: hàng ngang hoặc cột dọc. Hai trục luôn đổi theo flex-row/flex-col.

Trụcflex-rowflex-col
Main axisngangdọc
Cross axisdọcngang
justify-*căn ngangcăn dọc
items-*căn dọccăn ngang

Toolbar chuẩn:

<header className="flex h-14 items-center gap-3 border-b px-4">
  <a className="shrink-0 font-semibold" href="/">
    Acme
  </a>

  <div className="min-w-0 flex-1">
    <SearchInput className="w-full" />
  </div>

  <Button variant="ghost" size="icon" className="shrink-0">
    <Settings className="size-4" />
  </Button>
</header>

Ba chi tiết production:

  • Logo/action dùng shrink-0 để không bị bóp.
  • Vùng search dùng min-w-0 flex-1 để được co.
  • gap-3 thuộc về parent, không rải mr-3 từng child.

min-w-0: class nhỏ, giá trị lớn

Flex item mặc định có min-width: auto, nghĩa là nó không muốn nhỏ hơn nội dung. Vì vậy một filename dài có thể phá cả row.

<div className="flex items-center gap-3 rounded-md border p-3">
  <FileIcon className="text-muted-foreground size-5 shrink-0" />

  <div className="min-w-0 flex-1">
    <p className="truncate text-sm font-medium">
      quarterly-report-final-final-v12-really-long-name.pdf
    </p>
    <p className="text-muted-foreground truncate text-xs">
      Updated by product-team@example.com
    </p>
  </div>

  <Button size="sm" variant="outline" className="shrink-0">
    Open
  </Button>
</div>

Nếu truncate không chạy trong flex row, kiểm tra min-w-0 trước khi nghi Tailwind.

flex-1, flex-auto, flex-none

UtilityNghĩa thực tếDùng khi
flex-1chiếm phần còn lại, bỏ qua intrinsic sizesearch input, main content
flex-autoco giãn nhưng vẫn tôn trọng kích thước ban đầucontent có width tự nhiên
flex-nonekhông grow, không shrinkbadge/action cần cố định
shrink-0không bị bóp nhỏicon, avatar, button

Một layout tốt thường có đúng một vùng flex-1 và nhiều vùng shrink-0.


5. Grid: hai chiều và responsive layout

Grid nên là lựa chọn đầu tiên cho page layout, dashboard, card matrix và form layout phức tạp.

<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
  <StatCard />
  <StatCard />
  <StatCard />
  <StatCard />
</section>

Mobile-first nghĩa là class không prefix áp cho mobile trước. md:grid-cols-2 chỉ override khi viewport đủ rộng.

App shell bằng grid

export function AppShell({ children }: { children: React.ReactNode }) {
  return (
    <div className="grid h-dvh grid-cols-1 md:grid-cols-[16rem_1fr]">
      <aside className="bg-muted/40 hidden border-r md:block">
        <Sidebar />
      </aside>

      <div className="grid min-w-0 grid-rows-[3.5rem_1fr] overflow-hidden">
        <header className="flex items-center gap-3 border-b px-4">
          <MobileNav />
          <SearchTrigger />
          <UserMenu className="ml-auto" />
        </header>

        <main className="min-h-0 overflow-y-auto p-4 md:p-6">{children}</main>
      </div>
    </div>
  );
}

Tại sao đây là pattern tốt:

  • h-dvh xử lý viewport động trên mobile tốt hơn h-screen;
  • md:grid-cols-[16rem_1fr] mô tả đúng kiến trúc sidebar/content;
  • grid-rows-[3.5rem_1fr] khóa topbar và cho main lấy phần còn lại;
  • min-w-0min-h-0 ngăn overflow ngầm;
  • sidebar mobile có thể chuyển thành Sheet thay vì cố ép nó vào grid.

Form layout responsive

<form className="grid gap-4 md:grid-cols-2">
  <Field name="firstName" label="First name" />
  <Field name="lastName" label="Last name" />
  <Field className="md:col-span-2" name="email" label="Email" />
  <Field className="md:col-span-2" name="bio" label="Bio" />
</form>

col-span-* nên dùng cho layout role, không dùng để “chữa cháy” từng pixel.


6. Spacing và sizing: nhịp thiết kế, không phải đoán số

Tailwind spacing scale mặc định dựa trên 0.25rem. Với root font size 16px:

ClassGiá trị
p-14px
p-28px
p-312px
p-416px
p-624px
p-832px

Đừng chọn số theo cảm giác từng chỗ. Hãy tạo nhịp:

  • component compact: px-2.5 py-1.5, gap-2;
  • form control: h-9 hoặc h-10, px-3;
  • card: p-4 hoặc p-6;
  • page section: py-10, py-12, py-16;
  • dashboard grid: gap-4, page padding p-4 md:p-6.

size-* cho icon/avatar/button vuông

<Button size="icon" className="size-9">
  <Search className="size-4" />
</Button>

<Avatar className="size-10" />

size-* tốt hơn viết h-* w-* lặp lại khi phần tử cần vuông.

w-full, max-w-*, w-fit

UtilityDùng khi
w-fullinput/card chiếm hết parent
max-w-prosecột bài viết dễ đọc
max-w-screen-xlcontainer page lớn
w-fitbadge/button vừa nội dung
w-[min(92vw,32rem)]công thức modal responsive

Arbitrary value không xấu. Nó xấu khi thay thế token thiết kế. Nếu giá trị là công thức layout riêng và xuất hiện một lần, arbitrary value rất hợp lý.


7. Typography: làm chữ dễ đọc trước khi làm chữ đẹp

Typography trong UI app khác typography landing page. Dashboard, form, table cần rõ, scan nhanh, ít kịch tính.

Một thang chữ thực dụng:

Vai tròUtility
Page titletext-2xl font-semibold tracking-tight
Section titletext-lg font-semibold
Card titletext-sm font-medium
Bodytext-sm leading-6
Helper texttext-xs text-muted-foreground
Table celltext-sm
Badgetext-xs font-medium

Ví dụ card header:

<header className="space-y-1.5">
  <h2 className="text-lg font-semibold tracking-tight">Billing</h2>
  <p className="text-muted-foreground text-sm">
    Manage invoices, payment method, and tax information.
  </p>
</header>

truncate, line-clamp, text-balance, text-pretty

UtilityDùng khi
truncatemột dòng, cắt bằng dấu ba chấm
line-clamp-2card title/description tối đa n dòng
break-wordschuỗi dài như URL/email/token cần xuống dòng
text-balanceheadline cân dòng đẹp hơn
text-prettyparagraph giảm orphan word

Đừng dùng truncate cho nội dung người dùng bắt buộc đọc đủ, ví dụ lỗi form quan trọng. Khi cắt text, nên có tooltip/title hoặc màn detail để xem đầy đủ.


8. Màu sắc: primitive color vs semantic token

Trong prototype, bg-blue-600 text-white ổn. Trong design system, component nên dùng semantic token:

// Prototype nhanh
<button className="rounded-md bg-blue-600 px-3 py-2 text-white">
  Save
</button>

// Component system
<button className="rounded-md bg-primary px-3 py-2 text-primary-foreground">
  Save
</button>

Sự khác biệt:

Cách viếtÝ nghĩa
bg-blue-600màu cụ thể
bg-primaryvai trò trong hệ thống
text-muted-foregroundchữ phụ, helper text, placeholder
border-borderđường chia mặc định
ring-ringfocus ring mặc định

shadcn dùng token semantic vì một component không nên biết brand hiện tại là xanh, tím hay cam. Component chỉ biết vai trò: primary, muted, destructive, card, popover.

Quy tắc background/foreground

Mỗi surface có foreground tương ứng:

SurfaceText/icon nên đi cùng
bg-backgroundtext-foreground
bg-cardtext-card-foreground
bg-popovertext-popover-foreground
bg-primarytext-primary-foreground
bg-secondarytext-secondary-foreground
bg-mutedthường text-muted-foreground
bg-accenttext-accent-foreground
bg-destructivethường text-white hoặc token destructive foreground nếu theme có

Khi text khó đọc, đừng sửa bằng text-white tùy tiện. Hãy kiểm tra cặp token surface/foreground.

/opacity khác opacity-*

<div className="bg-black/50">Overlay</div>
<div className="opacity-50">Whole element is faded</div>

bg-black/50 chỉ làm trong suốt màu nền. Text/icon bên trong vẫn rõ. opacity-50 làm mờ cả element và children. Với overlay, dùng màu có alpha. Với disabled state, dùng disabled:opacity-50.


9. Borders, rings, shadows: interactive surface

Một input production thường cần đủ bốn tầng: border, background, text, focus ring.

<input
  className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm transition-colors outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50"
  placeholder="you@example.com"
/>

Giải phẫu:

  • border border-input: đường viền theo theme form control;
  • bg-background text-foreground: surface và chữ theo token;
  • shadow-sm: nổi rất nhẹ;
  • outline-none focus-visible:ring-2 focus-visible:ring-ring: tự vẽ focus ring thay outline mặc định;
  • disabled:*: trạng thái bất hoạt rõ ràng.

ring vs outline

Công cụĐặc điểm
outline-*CSS outline thật, nằm ngoài border, tốt cho accessibility
ring-*box-shadow mô phỏng vòng, linh hoạt trong design system

shadcn thường dùng ring-ring/outline-ring/50 để focus state đồng bộ theme. Dù dùng cách nào, focus keyboard phải nhìn thấy rõ. Đừng xóa outline nếu chưa thay bằng focus indicator khác.


10. Variants: điều kiện áp dụng utility

Variant là tiền tố điều kiện:

<button className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50">
  Save
</button>

Đọc là:

  • bình thường: bg-primary text-primary-foreground;
  • khi hover: bg-primary/90;
  • khi focus bằng keyboard: có ring;
  • khi disabled: không nhận pointer event và mờ đi.

Một variant áp cho đúng một utility. Muốn hover đổi ba thứ, lặp prefix ba lần:

<button className="hover:bg-accent hover:text-accent-foreground hover:shadow-sm">
  Hover me
</button>

data-* là cầu nối với Radix

Radix phơi state qua data attribute như data-state="open" hoặc data-disabled. Tailwind style trực tiếp được:

<DropdownMenu.Item className="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
  Rename
</DropdownMenu.Item>

Vì vậy học data-[...] không phải “syntax lạ”. Nó là cách style component headless mà không cần state class tự chế.

group, peer, has

VariantKhi nào dùng
group-hover:*child đổi theo state của parent
peer-checked:*sibling đổi theo state của input/sibling trước nó
has-*parent đổi theo descendant

Card hover:

<a className="group hover:bg-accent block rounded-lg border p-4 transition-colors">
  <h3 className="group-hover:text-accent-foreground font-medium">
    Project Aurora
  </h3>
  <p className="text-muted-foreground group-hover:text-accent-foreground/80 text-sm">
    Open dashboard
  </p>
</a>

Form row dùng has:

<label className="has-[:checked]:border-primary has-[:checked]:bg-primary/5 flex items-start gap-3 rounded-md border p-3">
  <input type="checkbox" className="mt-1" />
  <span>
    <span className="block text-sm font-medium">Enable alerts</span>
    <span className="text-muted-foreground block text-sm">
      Send notifications when usage exceeds the limit.
    </span>
  </span>
</label>

11. SVG và icon: để icon kế thừa màu

Icon trong app nên đi theo text color, không hard-code màu trong SVG.

<Button variant="ghost" size="icon">
  <Search className="text-muted-foreground size-4" />
</Button>

Với icon library như Lucide, size-4 đặt width/height, text-muted-foreground điều khiển stroke vì icon dùng currentColor.

Tình huốngClass
Icon trong buttonsize-4
Icon lớn hơn trong empty statesize-8 text-muted-foreground
Icon không bị co trong rowsize-4 shrink-0
SVG tự viết cần fill theo textfill-current
SVG tự viết cần stroke theo textstroke-current

Không dùng icon chỉ để trang trí nếu nó làm rối screen reader. Với icon decorative trong button có text, thường đặt aria-hidden="true".


12. @theme: khi utility trở thành design system

Trong Tailwind v4, @theme định nghĩa theme variables. Chúng không chỉ là CSS variables; chúng còn quyết định utility nào tồn tại.

@import 'tailwindcss';

@theme {
  --color-brand: oklch(0.62 0.18 255);
  --radius-card: 0.75rem;
  --spacing-page: 1.5rem;
  --shadow-card: 0 1px 2px rgb(0 0 0 / 0.06);
}

Từ đó bạn có:

<article className="rounded-card bg-brand p-page shadow-card">...</article>

Namespace quyết định utility sinh ra:

NamespaceUtility sinh ra
--color-*bg-*, text-*, border-*, ring-*, fill-*, from-*
--font-*font-* font-family
--text-*text-* font-size
--font-weight-*font-* font-weight
--tracking-*tracking-*
--leading-*leading-*
--spacing-*p-*, m-*, gap-*, w-*, h-*, inset-*, size-*
--radius-*rounded-*
--shadow-*shadow-*
--blur-*blur-*
--breakpoint-*responsive variants như tablet:*
--container-*container query variants và container sizes
--aspect-*aspect-*
--ease-*ease-*
--animate-*animate-*

Quy tắc:

  • Muốn bg-brand, tạo --color-brand.
  • Muốn rounded-card, tạo --radius-card.
  • Muốn p-page, tạo --spacing-page.
  • Muốn tablet:grid, tạo --breakpoint-tablet.

Token sai namespace là lỗi rất hay gặp:

/* Sai: không sinh rounded-card */
@theme {
  --rounded-card: 0.75rem;
}

/* Đúng */
@theme {
  --radius-card: 0.75rem;
}

@theme vs :root

DùngKhi
@themeBạn muốn Tailwind sinh utility từ token
:rootBạn chỉ cần CSS variable runtime, không cần utility
@theme {
  --color-brand: oklch(0.62 0.18 255); /* sinh bg-brand */
}

:root {
  --app-sidebar-width: 16rem; /* biến runtime nội bộ */
}

Đừng đưa mọi biến vào @theme. Chỉ token thiết kế cần utility mới nên vào đó.

@theme inline trong shadcn

shadcn thường có token semantic ở :root/.dark, rồi nối sang Tailwind bằng @theme inline.

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
}

Component dùng utility, không biết light/dark cụ thể:

<main className="bg-background text-foreground">
  <Button className="bg-primary text-primary-foreground">Upgrade</Button>
</main>

Đây là điểm trưởng thành của hệ thống: component dùng vai trò semantic, theme quyết định giá trị thật.


13. Recipe production: từ class rời thành component

Button primitive

import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';

import { cn } from '@/lib/utils';

const buttonVariants = cva(
  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        secondary:
          'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        outline:
          'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        destructive: 'bg-destructive text-white hover:bg-destructive/90',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
        icon: 'size-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

export function Button({
  className,
  variant,
  size,
  asChild = false,
  ...props
}: React.ComponentProps<'button'> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean;
  }) {
  const Comp = asChild ? Slot : 'button';
  return (
    <Comp
      data-slot="button"
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  );
}

Một component như vậy gom nhiều phần đã học:

  • layout: inline-flex items-center justify-center gap-2;
  • typography: text-sm font-medium;
  • state: focus-visible:*, disabled:*, hover:*;
  • tokens: bg-primary, text-primary-foreground, border-input;
  • icon contract: [&_svg]:size-4;
  • extensibility: asChild, cva, cn.

Card dashboard

<article className="bg-card text-card-foreground rounded-lg border p-4 shadow-sm">
  <div className="flex items-start justify-between gap-3">
    <div className="min-w-0">
      <p className="text-muted-foreground truncate text-sm">Monthly revenue</p>
      <p className="mt-2 text-2xl font-semibold tracking-tight">$48,230</p>
    </div>
    <Badge variant="secondary" className="shrink-0">
      +12.4%
    </Badge>
  </div>
</article>

Điểm production:

  • card dùng bg-card text-card-foreground;
  • content dài có min-w-0;
  • badge/action không bị co nhờ shrink-0;
  • không dùng shadow quá mạnh trong dashboard.

Empty state

<div className="flex min-h-64 flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
  <Inbox className="text-muted-foreground size-10" />
  <h3 className="mt-4 text-base font-semibold">No invoices yet</h3>
  <p className="text-muted-foreground mt-1 max-w-sm text-sm">
    Create your first invoice or import existing billing data from CSV.
  </p>
  <Button className="mt-4">Create invoice</Button>
</div>

Empty state cần có:

  • icon hoặc visual nhỏ;
  • heading rõ;
  • mô tả nêu trạng thái và hướng đi;
  • action chính nếu người dùng có thể tự xử lý.

Data table shell

<div className="overflow-hidden rounded-lg border">
  <div className="overflow-x-auto">
    <table className="w-full text-sm">
      <thead className="bg-muted/50 text-muted-foreground">
        <tr className="border-b">
          <th className="h-10 px-3 text-left font-medium">Customer</th>
          <th className="h-10 px-3 text-left font-medium">Status</th>
          <th className="h-10 px-3 text-right font-medium">Amount</th>
        </tr>
      </thead>
      <tbody>
        {rows.map((row) => (
          <tr className="hover:bg-muted/50 border-b last:border-0">
            <td className="max-w-[18rem] px-3 py-2">
              <p className="truncate font-medium">{row.customer}</p>
              <p className="text-muted-foreground truncate text-xs">
                {row.email}
              </p>
            </td>
            <td className="px-3 py-2">
              <Badge variant="outline">{row.status}</Badge>
            </td>
            <td className="px-3 py-2 text-right tabular-nums">{row.amount}</td>
          </tr>
        ))}
      </tbody>
    </table>
  </div>
</div>

Table tốt cần:

  • wrapper overflow-x-auto cho mobile;
  • tabular-nums cho số thẳng cột;
  • text-right cho tiền/số lượng;
  • max-w-* truncate cho cell có text dài;
  • hover state nhẹ, không làm loạn màu.

14. Debug checklist: khi Tailwind “không ăn”

1. Class có được generate không?

Nếu bạn ghép class động, Tailwind scanner không thấy:

// Sai
const className = `bg-${color}-500`;

// Đúng
const colorClass = {
  success: 'bg-emerald-500',
  warning: 'bg-amber-500',
  danger: 'bg-red-500',
}[tone];

Nếu DevTools không thấy rule cho class đó, vấn đề là build/scanner, không phải specificity.

2. Class có conflict không?

<div className="p-2 p-4" />

Hai class cùng nhóm padding. Class sau thắng theo thứ tự CSS generated/merge. Trong component React, dùng tailwind-merge qua cn() để caller override an toàn:

className={cn("rounded-md px-3 py-2", className)}

3. truncate không chạy?

Checklist:

  • parent có width giới hạn chưa?
  • flex item chứa text có min-w-0 chưa?
  • text element có overflow-hidden whitespace-nowrap text-ellipsis chưa? truncate gói ba cái này.

4. overflow-y-auto không cuộn?

Checklist:

  • parent có chiều cao rõ chưa (h-dvh, h-full, grid row)?
  • child trong grid/flex có min-h-0 chưa?
  • có parent nào đang overflow-visible làm page cuộn thay vì region không?

5. Focus ring không thấy?

Checklist:

  • element focusable không?
  • outline-none nhưng quên focus-visible:ring-* không?
  • ring màu có đủ contrast không?
  • parent có overflow-hidden cắt mất ring không?

6. Dark mode lệch?

Checklist:

  • app có class .dark trên html hoặc root đúng chưa?
  • token được override cùng tên trong .dark chưa?
  • component có dùng semantic token hay hard-code bg-white text-black?
  • @theme inline có trỏ token đúng không?

7. Modal bị nằm dưới header?

Checklist:

  • modal render trong portal lên cuối body chưa?
  • backdrop/content có fixed chưa?
  • z-index có bị stacking context của parent chặn không?
  • parent có transform/opacity/filter tạo stacking context không?

15. Quy tắc chọn utility, token, arbitrary value

Tình huốngChọn
Giá trị thuộc hệ thống thiết kế và dùng nhiều nơi@theme token
Giá trị là vai trò UI như nền app, chữ phụ, action chínhsemantic token (bg-background, text-muted-foreground)
Giá trị là layout formula một lầnarbitrary value (w-[min(92vw,32rem)])
Giá trị là spacing/radius/font scale mặc địnhutility mặc định (p-4, rounded-md, text-sm)
Cụm class lặp lại với hành vicomponent
Cụm class có nhiều variant/sizecva
State đến từ Radixdata-[state=...] hoặc shadcn component có sẵn

Một dấu hiệu bạn đang đi đúng hướng: component càng ngày càng dùng nhiều token semantic và ít màu hard-code.


16. Bài tập: từ zero đến có kinh nghiệm

Bài 1 — Toolbar không vỡ layout

Yêu cầu:

  • logo trái;
  • search input giữa;
  • user menu phải;
  • search text dài không làm vỡ layout;
  • mobile vẫn ổn.

Gợi ý class: flex, items-center, gap-3, shrink-0, min-w-0, flex-1, w-full.

Bài 2 — Dashboard grid

Yêu cầu:

  • mobile 1 cột;
  • tablet 2 cột;
  • desktop 4 cột;
  • card dùng token shadcn;
  • số dùng tabular-nums.

Gợi ý class: grid, gap-4, sm:grid-cols-2, xl:grid-cols-4, bg-card, text-card-foreground, text-muted-foreground.

Bài 3 — Modal shell

Yêu cầu:

  • backdrop phủ màn;
  • content căn giữa;
  • width responsive;
  • focus ring cho action;
  • không dùng opacity-50 trên wrapper content.

Gợi ý class: fixed, inset-0, z-50, bg-black/50, w-[min(92vw,32rem)], -translate-x-1/2, focus-visible:ring-2.

Bài 4 — Token hóa theme

Yêu cầu:

  • tạo --color-brand;
  • tạo --radius-card;
  • tạo --spacing-page;
  • dùng bg-brand, rounded-card, p-page;
  • sau đó chuyển component sang semantic token nếu nó là component dùng chung.

Bài 5 — Debug thực chiến

Tự tạo ba lỗi rồi sửa:

  1. truncate không chạy trong flex row.
  2. bg-${color}-500 không generate CSS.
  3. Dark mode đổi nền nhưng chữ vẫn sai contrast.

Khi bạn tự sửa được ba lỗi này bằng DevTools, bạn đã vượt khỏi mức “biết class” và bắt đầu dùng Tailwind như một engineer.


Điểm chính

  • Tailwind là API ngắn và nhất quán cho CSS, không phải phép màu thay CSS.
  • Hãy phân loại vấn đề trước: layout, spacing, sizing, typography, state, token.
  • Flex giải quyết một chiều; grid giải quyết hai chiều.
  • min-w-0min-h-0 là hai class nhỏ cứu rất nhiều layout flex/grid.
  • shadcn component nên dùng semantic token: bg-background, text-foreground, border-border, ring-ring.
  • @theme tạo utility; :root chỉ tạo CSS variable runtime.
  • Arbitrary value hợp lý khi nó là công thức layout riêng, không phải token thiết kế bị bỏ quên.
  • Debug Tailwind bằng DevTools: xem class có rule không, rule có bị conflict không, token có tồn tại không.

Kết series

Nếu đi từ Phần 1 tới đây, bạn đã có đủ ba tầng:

  1. Tailwind để mô tả visual system bằng utility và token.
  2. Radix để lấy hành vi headless, accessible, composable.
  3. shadcn/ui để sở hữu component production, theme được, sửa được, mở rộng được.

Đích cuối không phải “thuộc nhiều class”. Đích cuối là nhìn một UI phức tạp và tự tin tách nó thành layout, state, token, component, accessibility, performance. Khi làm được điều đó, Tailwind không còn là cheatsheet nữa. Nó trở thành ngôn ngữ thiết kế bạn dùng hằng ngày.