Tailwind, Radix & shadcn/ui · Part 7 — Radix UI Primitives: Headless & Accessible
The second pillar: why headless components exist, installing Radix, the Root/Trigger/Portal/Content anatomy, and building a fully accessible Dialog and DropdownMenu styled with Tailwind. With a live anatomy + a11y demo.
You can now style anything {Giờ bạn style được mọi thứ}. But styling is the easy 20% of a real component {Nhưng style chỉ là 20% dễ của một component thật}. The hard 80% is behavior and accessibility: focus trapping, keyboard navigation, ARIA roles, dismiss-on-Escape, returning focus, portalling above everything {80% khó là hành vi và khả năng tiếp cận: bẫy focus, điều hướng bàn phím, vai trò ARIA, đóng bằng Escape, trả focus, portal lên trên mọi thứ}. Radix UI gives you exactly that — and no styles — so you bring the looks with Tailwind {Radix UI cho bạn đúng phần đó — và không style — để bạn mang vẻ ngoài bằng Tailwind}.
1. What “headless” means {“Headless” nghĩa là gì}
A headless component ships behavior, state, and accessibility but renders unstyled markup {Một component headless mang hành vi, trạng thái, khả năng tiếp cận nhưng render markup không style}. You own 100% of the appearance {Bạn sở hữu 100% vẻ ngoài}. This is the opposite of a traditional library like Bootstrap or MUI, which bundles opinionated styles you then fight to override {Đây là ngược lại với thư viện truyền thống như Bootstrap hay MUI, vốn gói sẵn style cứng nhắc rồi bạn phải vật lộn override}.
Why this matters {Vì sao quan trọng}: accessible interactive components are genuinely hard to get right {component tương tác dễ tiếp cận thật sự khó làm đúng}. A correct dialog must trap focus, restore it on close, be dismissible by Escape and outside-click, mark aria-modal, and hide the rest of the page from screen readers {Một dialog đúng phải bẫy focus, khôi phục khi đóng, đóng được bằng Escape và bấm ngoài, đánh dấu aria-modal, và ẩn phần còn lại của trang khỏi screen reader}. Radix has solved these once, correctly, for everyone {Radix đã giải các bài này một lần, đúng đắn, cho tất cả}.
Experience the behavior — open the Dialog and try Tab/Esc; open the menu and use arrow keys {Trải nghiệm hành vi — mở Dialog rồi thử Tab/Esc; mở menu rồi dùng phím mũi tên}:
2. Install {Cài đặt}
Radix primitives are separate packages per component, so you only ship what you use {Radix primitives là gói riêng cho mỗi component, nên bạn chỉ ship cái mình dùng}:
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu
(There’s also a convenience radix-ui package that re-exports everything — handy, but per-package keeps bundles lean) {(Cũng có gói tiện radix-ui re-export tất cả — tiện, nhưng gói riêng giữ bundle gọn)}.
3. The anatomy: Root / Trigger / Portal / Content {Giải phẫu: Root / Trigger / Portal / Content}
Every Radix primitive is a set of composable parts you assemble {Mỗi Radix primitive là một bộ các phần ghép được mà bạn lắp}. A Dialog {Một Dialog}:
import * as Dialog from '@radix-ui/react-dialog';
export function EditProfileDialog() {
return (
<Dialog.Root>
<Dialog.Trigger className="rounded-lg border px-4 py-2 hover:bg-accent/10">
Edit profile
</Dialog.Trigger>
<Dialog.Portal>
{/* backdrop — exposes data-state for animation */}
<Dialog.Overlay
className="fixed inset-0 bg-black/60
data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
{/* the panel — focus-trapped, role=dialog, aria-modal */}
<Dialog.Content
className="fixed left-1/2 top-1/2 w-[420px] -translate-x-1/2 -translate-y-1/2
rounded-xl border bg-surface p-6 shadow-2xl
data-[state=open]:animate-in data-[state=open]:zoom-in-95"
>
<Dialog.Title className="text-lg font-semibold">Edit profile</Dialog.Title>
<Dialog.Description className="mt-1 text-sm text-muted-foreground">
Make changes, then save.
</Dialog.Description>
<input className="mt-4 w-full rounded-md border px-3 py-2" />
<div className="mt-4 flex justify-end gap-2">
<Dialog.Close className="rounded-md border px-4 py-2">Cancel</Dialog.Close>
<button className="rounded-md bg-indigo-500 px-4 py-2 text-white">Save</button>
</div>
<Dialog.Close className="absolute right-3 top-3" aria-label="Close">✕</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Each part has a job {Mỗi phần một nhiệm vụ}:
| Part | Responsibility |
|---|---|
Root | holds open/closed state, wires everything together |
Trigger | the button; toggles state, gets aria-haspopup/aria-expanded |
Portal | renders Content at the end of <body> so it escapes overflow/z-index traps |
Overlay | the backdrop; click-to-dismiss; data-state |
Content | the panel; focus trap, role="dialog", aria-modal, Escape handling |
Title/Description | wire aria-labelledby/aria-describedby automatically |
Close | any element that closes the dialog |
Notice: you supply every className — Radix never imposes a style {Để ý: bạn cấp mọi className — Radix không bao giờ áp style}. And the styling uses exactly the data-[state=open]: variants and animate-in utilities from Parts 4 and 6 {Và phần style dùng đúng variant data-[state=open]: và utility animate-in từ Phần 4 và 6}.
4. A DropdownMenu — keyboard-complete {Một DropdownMenu — đầy đủ bàn phím}
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
export function OptionsMenu() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger className="rounded-lg border px-4 py-2">Options ▾</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={6}
className="min-w-[200px] rounded-lg border bg-surface p-1 shadow-xl
data-[state=open]:animate-in data-[state=open]:fade-in-0
data-[side=bottom]:slide-in-from-top-1"
>
<DropdownMenu.Item className="flex justify-between rounded px-2 py-1.5 text-sm
outline-none data-[highlighted]:bg-accent/15">
Profile <span className="text-xs text-muted-foreground">⌘P</span>
</DropdownMenu.Item>
<DropdownMenu.Item className="... data-[highlighted]:bg-accent/15">Settings</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-border" />
<DropdownMenu.Item className="text-red-500 data-[highlighted]:bg-red-500/15">
Log out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
You get arrow-key navigation, typeahead, Home/End, Escape, outside-click, roving tabindex, and aria-* wiring — for free {Bạn có điều hướng phím mũi tên, typeahead, Home/End, Escape, bấm ngoài, tabindex luân chuyển, và aria-* — miễn phí}. The styling hook is data-[highlighted] (Radix sets it on the active item, mouse or keyboard) {Móc style là data-[highlighted] (Radix gắn nó lên item đang active, chuột hoặc bàn phím)}.
5. The styling contract: data-* attributes {Hợp đồng style: thuộc tính data-*}
This is the through-line of the whole series {Đây là sợi chỉ xuyên suốt cả series}. Radix exposes state as data attributes; Tailwind styles them {Radix phơi trạng thái thành data attribute; Tailwind style chúng}:
Radix data-* | Style it with |
|---|---|
data-state="open" / "closed" | data-[state=open]:… |
data-state="checked" (toggles) | data-[state=checked]:… |
data-highlighted (menu item) | data-[highlighted]:… |
data-disabled | data-[disabled]:… |
data-side="top/bottom/…" | data-[side=bottom]:… |
Memorize this pairing and you can style any Radix primitive on first sight {Nhớ cặp đôi này là bạn style được bất kỳ Radix primitive nào ngay lần đầu thấy}.
6. Exercises {Bài tập}
1. In one sentence, what does Radix provide and what does it deliberately not provide? {Trong một câu, Radix cung cấp gì và cố tình không cung cấp gì?}
Solution {Lời giải}
It provides behavior, state, and accessibility (focus, keyboard, ARIA); it deliberately provides no styles {Nó cung cấp hành vi, trạng thái, khả năng tiếp cận; cố tình không style}.
2. Why does Dialog.Content need to be inside Dialog.Portal? {Vì sao Dialog.Content cần nằm trong Dialog.Portal?}
Solution {Lời giải}
The portal renders it at the end of <body>, escaping ancestor overflow: hidden and z-index stacking contexts so the dialog always sits on top {Portal render nó ở cuối <body>, thoát khỏi overflow: hidden và ngữ cảnh xếp z-index của tổ tiên để dialog luôn nằm trên}.
3. Which Tailwind variant styles a Radix menu item while it’s keyboard-or-mouse “active”? {Variant Tailwind nào style một menu item của Radix khi nó đang “active” bằng phím hoặc chuột?}
Solution {Lời giải}
data-[highlighted]:… {data-[highlighted]:…}
Stretch {Nâng cao}: in the demo, open the Dialog and confirm two a11y behaviors with the keyboard alone — focus is trapped on Tab, and closing returns focus to the trigger {trong demo, mở Dialog và xác nhận hai hành vi a11y chỉ bằng bàn phím — focus bị bẫy khi Tab, và đóng sẽ trả focus về trigger}.
Key takeaways {Điểm chính}
- Headless = behavior + accessibility, zero styles — you own the looks {Headless = hành vi + khả năng tiếp cận, không style — bạn sở hữu vẻ ngoài}.
- Radix primitives are composable parts (
Root/Trigger/Portal/Content/…) you assemble and className yourself {Radix primitives là các phần ghép được mà bạn lắp và tự gắn className}. - The styling contract is
data-*attributes ↔ Tailwinddata-[…]:variants {Hợp đồng style là thuộc tínhdata-*↔ variantdata-[…]:của Tailwind}. - Radix solves the genuinely hard parts (focus, keyboard, ARIA, portal) once, correctly {Radix giải các phần thật sự khó (focus, bàn phím, ARIA, portal) một lần, đúng đắn}.
Next up {Tiếp theo}
Part 8 — Radix deep: controlled vs uncontrolled state, the asChild pattern (merge behavior onto your own element), composition across Tooltip/Popover/Tabs/Switch, and a reusable styling strategy — everything you need before shadcn ties it all together {Phần 8 — Radix chuyên sâu: state controlled vs uncontrolled, mẫu asChild (ghép hành vi lên phần tử của bạn), kết hợp qua Tooltip/Popover/Tabs/Switch, và chiến lược style tái sử dụng — mọi thứ cần trước khi shadcn ráp tất cả lại}.