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.
Giờ bạn style được mọi thứ. Nhưng style chỉ là 20% dễ của một component thật. 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 cho bạn đúng phần đó — và không style — để bạn mang vẻ ngoài bằng Tailwind. Đây là trụ cột thứ hai của stack: Tailwind lo vẻ ngoài, Radix lo hành vi, và data attribute là chiếc cầu nối hai bên.
Khi nào nên dùng Radix, khi nào không
Radix đáng giá nhất ở những component có hành vi tương tác phức tạp. Nếu chỉ cần một thẻ tĩnh, đừng kéo primitive vào cho sang.
| Nhu cầu | Nên dùng |
|---|---|
| Button, badge, card tĩnh | HTML + Tailwind + cva nếu có variant |
| Dialog, popover, dropdown, tooltip, select | Radix primitive |
| Tabs/switch/checkbox/radio cần keyboard + ARIA chuẩn | Radix primitive |
| Layout panel/sidebar tĩnh | CSS Grid/Flex + component của bạn |
| Animation vào/ra cho layer nổi | Radix data-state + tw-animate-css |
Một rule thực tế: nếu component cần quản focus, keyboard navigation, dismiss outside, portal, hoặc ARIA dynamic, hãy dùng primitive. Nếu không có những thứ đó, HTML chuẩn thường đủ và nhẹ hơn.
Radix không thay thế HTML semantics. Nó giúp bạn ở phần khó, nhưng bạn vẫn phải chọn đúng intent: trigger nên là <button> khi thực hiện hành động, <a> khi điều hướng; title/description phải có nội dung thật; disabled phải dùng prop/attribute thật chứ không chỉ opacity-50.
1. “Headless” nghĩa là gì
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. Bạn sở hữu 100% vẻ ngoài. Đâ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.
Tách bạch cho rõ — một component tương tác có bốn lớp, và headless cắt ngay ở giữa:
┌─────────────────────────────────────────────┐
│ 4. Vẻ ngoài (màu, padding, bo góc, font) │ ← BẠN lo (Tailwind)
├─────────────────────────────────────────────┤
│ 3. Cấu trúc DOM tối thiểu (div, button…) │ ← Radix render, bạn gắn className
│ 2. Khả năng tiếp cận (ARIA, role, focus) │ ← Radix lo
│ 1. Hành vi (open/close, phím, dismiss) │ ← Radix lo
└─────────────────────────────────────────────┘
"headless" = ship lớp 1–3, KHÔNG lớp 4
Vì sao đường cắt này quan trọng: component tương tác dễ tiếp cận thật sự khó làm đúng, nhưng phần khó đó giống nhau ở mọi app — một dialog ở app A và app B cần bẫy focus y hệt, chỉ khác màu sắc. Thư viện có style (MUI, Bootstrap) gộp cả bốn lớp, nên bạn nhận luôn phần khó kèm style không hợp ý, rồi tốn công override. Headless tách phần khó (tái dùng được) khỏi phần dễ-nhưng-riêng (vẻ ngoài), trả lại lớp 4 cho bạn.
Chốt: “headless” không có nghĩa “không có UI” — nó có nghĩa “có hành vi + a11y, không có CSS”. Bạn vẫn thấy DOM thật, vẫn có button bấm được; chỉ là nó trần trụi cho đến khi bạn gắn class Tailwind vào.
So sánh ba kiểu thư viện để định vị Radix:
| Kiểu | Ví dụ | Hành vi & a11y | Style | Bạn override style? |
|---|---|---|---|---|
| Styled | MUI, Bootstrap, Ant | Có | Gói cứng sẵn | Phải, thường đau |
| Headless | Radix, Headless UI, React Aria | Có | Không | Không cần — bạn là style |
| CSS-only | Pico, Bulma | Không (chỉ CSS) | Có | N/A — không có JS |
2. Vì sao Radix tồn tại — 6 bài toán khó
Khi bạn tự viết một dropdown bằng useState và vài cái div, nó “chạy” với chuột. Nhưng bạn vừa bỏ qua sáu bài toán mà người dùng bàn phím và screen reader sẽ vấp ngay. Radix giải sẵn cả sáu:
| # | Bài toán | Nếu thiếu thì sao | Radix làm gì |
|---|---|---|---|
| 1 | Focus management | Mở modal nhưng focus vẫn ở trang dưới; đóng xong focus “bay” về đầu trang | Đưa focus vào panel khi mở, trả về trigger khi đóng |
| 2 | Focus trap | Tab trong modal nhảy ra link phía sau backdrop | Giữ Tab/Shift+Tab vòng trong panel |
| 3 | Keyboard nav | Menu chỉ bấm được bằng chuột | Phím ↑↓, Home/End, typeahead, Esc |
| 4 | ARIA wiring | Screen reader đọc “button” trống rỗng, không biết là menu/dialog | Tự gắn role, aria-expanded, aria-haspopup, aria-labelledby… |
| 5 | Portal & layering | Panel bị cha overflow:hidden cắt mất, hoặc z-index thua | Render ở cuối <body>, thoát mọi bẫy stacking |
| 6 | Dismiss layer | Bấm ra ngoài không đóng; nhiều popup chồng nhau đóng sai thứ tự | Lớp dismiss thống nhất: click-outside, Esc, đóng theo ngăn xếp |
Trong sáu cái, focus trap và trả focus là thứ gần như không ai tự làm đúng. Hình dung luồng focus của một Dialog đúng chuẩn:
[Trigger] ──bấm──▶ mở Dialog
▲ │
│ focus nhảy vào Content (phần tử focus đầu tiên)
│ │
│ Tab ⟳ vòng TRONG Content (không thoát ra sau backdrop)
│ │
└──trả focus── Esc / bấm ngoài / nút Close → đóng
(focus quay về đúng [Trigger] đã mở)
Mỗi mũi tên là một dòng code dễ quên. Radix biến cả sơ đồ này thành “lắp component vào là có”.
Trải nghiệm trực tiếp — mở Dialog rồi thử Tab/Esc; mở menu rồi dùng phím mũi tên. Để ý focus không bao giờ lọt ra sau lớp backdrop:
3. Cài đặt
Radix primitives là gói riêng cho mỗi component, nên bạn chỉ ship cái mình dùng — tree-shaking ở mức package:
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu
Mỗi primitive một gói @radix-ui/react-<tên>. Cũng có một gói gộp re-export tất cả, tiện khi prototype:
# gói gộp — import nhiều primitive từ một chỗ
npm install radix-ui
// dùng gói gộp: mọi primitive nằm dưới một import
import { Dialog, DropdownMenu, Tooltip } from 'radix-ui';
| Cách cài | Import | Khi nào dùng |
|---|---|---|
Gói riêng @radix-ui/react-dialog | import * as Dialog from '@radix-ui/react-dialog' | Production — chỉ kéo cái đang dùng |
Gói gộp radix-ui | import { Dialog } from 'radix-ui' | Prototype, hoặc dùng rất nhiều primitive |
Lưu ý phiên bản: từ giữa 2024 Radix gộp các primitive vào một gói
radix-uivà đặt lại version chung. Cả hai cách ở trên đều dùng được; tài liệu cũ hay viết@radix-ui/react-*, đó vẫn là cách chuẩn cho từng gói riêng.
4. Giải phẫu một primitive: mẫu compound Root/Trigger/Portal/Content/Item
Mọi Radix primitive theo một khuôn mẫu chung: nó không phải một component nhận hàng chục prop, mà là một họ component con ghép lại (compound component). Bạn lắp chúng như Lego, và chúng ngầm chia sẻ state qua React Context — nên bạn không phải tự truyền open, setOpen xuống từng cái.
Khuôn mẫu phổ quát, đọc một lần là nhận ra ở mọi primitive:
<X.Root> ← chủ state (open/value/checked…), cấp Context
<X.Trigger /> ← phần tử kích hoạt; nhận aria-* tự động
<X.Portal> ← (với overlay) teleport ra cuối <body>
<X.Overlay /> ← nền mờ phía sau (Dialog/AlertDialog)
<X.Content> ← khung nổi; focus, ARIA, dismiss
<X.Item /> ← phần tử con lặp lại (menu/select/tabs)
<X.Close /> ← bất kỳ thứ gì đóng lại
</X.Content>
</X.Portal>
</X.Root>
Áp vào Dialog cụ thể:
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 — phơi data-state để animate */}
<Dialog.Overlay
className="fixed inset-0 bg-black/60
data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
{/* panel — bị bẫy focus, 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>
);
}
Mỗi phần một nhiệm vụ — bảng này áp gần như nguyên xi cho mọi primitive có overlay:
| Phần | Trách nhiệm | a11y tự động |
|---|---|---|
Root | Giữ state open/closed, cấp Context cho mọi con | — |
Trigger | Nút kích hoạt; toggle state | aria-haspopup, aria-expanded, aria-controls |
Portal | Render Content ở cuối <body> → thoát overflow/z-index | — |
Overlay | Nền mờ; bấm để đóng; phơi data-state | Chặn tương tác nền |
Content | Khung nổi; bẫy focus, Escape, click-outside | role="dialog", aria-modal="true" |
Title | Tiêu đề | nối aria-labelledby |
Description | Mô tả | nối aria-describedby |
Close | Bất kỳ phần tử nào đóng dialog | — |
Hai điều cốt lõi cần ghi nhớ:
- Bạn cấp mọi
className. Radix không bao giờ tự áp style. Không cóclassNamethì component vẫn chạy đúng nhưng trần trụi. - Title/Description không chỉ để nhìn — chúng nối dây ARIA. Bỏ
Title, screen reader đọc dialog mà không có tên. Nếu muốn ẩn tiêu đề về mặt thị giác mà vẫn giữ a11y, dùngVisuallyHidden(Phần 8) chứ đừng bỏ hẳn.
5. Controlled vs uncontrolled — căn bản
Mọi primitive có state (open, value, checked…) chạy được ở hai chế độ. Đây là khái niệm React chuẩn, Radix chỉ áp dụng nhất quán:
Uncontrolled — Radix tự giữ state bên trong. Bạn chỉ lắp component, không cần useState. Dùng cho 90% trường hợp:
// uncontrolled: Radix tự nhớ open/closed; tùy chọn đặt mặc định
<Dialog.Root defaultOpen={false}>
{/* … */}
</Dialog.Root>
Controlled — bạn giữ state và truyền vào. Cần khi state phải đồng bộ với thứ khác (URL, store, đóng dialog này khi mở dialog kia, đóng sau khi fetch xong…):
// controlled: bạn sở hữu state qua cặp value + onValueChange
function Demo() {
const [open, setOpen] = useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
{/* giờ bạn có thể setOpen(false) sau khi lưu thành công */}
</Dialog.Root>
);
}
Quy ước đặt tên prop đồng nhất trên toàn Radix — học một lần, áp mọi primitive:
| Primitive | Uncontrolled (mặc định) | Controlled (cặp prop) |
|---|---|---|
| Dialog, Popover, Tooltip… | defaultOpen | open + onOpenChange |
| Tabs, Select, RadioGroup… | defaultValue | value + onValueChange |
| Switch, Checkbox… | defaultChecked | checked + onCheckedChange |
| Accordion | defaultValue | value + onValueChange |
Quy tắc ngón cái: mặc định cứ dùng uncontrolled — ít code, ít bug. Chỉ chuyển sang controlled khi bạn thực sự cần đọc hoặc đặt state từ bên ngoài. Đừng controlled “cho chắc”.
6. Hợp đồng style: thuộc tính data-*
Đây là sợi chỉ xuyên suốt cả series, và là lý do Radix + Tailwind hợp nhau đến vậy. Radix phơi mọi trạng thái nội bộ ra thành data-* attribute trên DOM; Tailwind có variant data-[…]: để style đúng các trạng thái đó. Không cần useState cho style, không cần class toggle bằng JS — CSS tự phản ứng theo attribute.
Cơ chế dưới nắp ca-pô:
Radix đổi state nội bộ
↓
Gắn/đổi attribute trên DOM: <div data-state="open">
↓
Tailwind compile data-[state=open]:opacity-100 →
[data-state="open"] & { opacity: 1 }
↓
Trình duyệt khớp selector → style áp NGAY, không qua JS
Bảng tra đầy đủ các data-* Radix hay phơi và cách style:
Radix data-* | Xuất hiện ở | Style bằng |
|---|---|---|
data-state="open" / "closed" | Dialog, Popover, Dropdown, Tooltip, Accordion… | data-[state=open]:… / data-[state=closed]:… |
data-state="checked" / "unchecked" | Switch, Checkbox, RadioGroup item | data-[state=checked]:… |
data-state="active" / "inactive" | Tabs trigger | data-[state=active]:… |
data-highlighted | Menu item, Select item (đang active bằng phím hoặc chuột) | data-[highlighted]:… |
data-disabled | Mọi item/trigger bị vô hiệu | data-[disabled]:… |
data-side="top/right/bottom/left" | Popover/Tooltip/Dropdown Content (hướng nó bung ra) | data-[side=bottom]:… |
data-align="start/center/end" | Content (canh theo trigger) | data-[align=start]:… |
data-orientation="horizontal/vertical" | Tabs, Slider, Accordion | data-[orientation=vertical]:… |
data-placeholder | Select value khi chưa chọn | data-[placeholder]:… |
Hai dòng quan trọng nhất là data-state (đóng/mở, bật/tắt) và data-highlighted (item đang active). Nhớ hai cái đó là style được phần lớn primitive ngay lần đầu thấy.
Vì sao dùng
data-*mà không phải class như.is-open? Vì Radix không biết hệ class của bạn, và data attribute là hợp đồng trung lập: bất kỳ CSS framework nào (Tailwind, vanilla, CSS Modules) đều khớp được[data-state="open"]. Radix giữ đúng vai trò headless — phơi trạng thái, không áp cách thể hiện.
7. Bảng tra các primitive thường dùng
Radix có ~30 primitive. Đây là những cái bạn sẽ gặp 95% thời gian, kèm phần chính và một dòng mục đích:
| Primitive | Phần chính | Mục đích một dòng |
|---|---|---|
| Dialog | Root/Trigger/Portal/Overlay/Content/Title/Close | Hộp thoại modal bẫy focus, chặn nền |
| AlertDialog | giống Dialog + Action/Cancel | Dialog xác nhận hành động phá hủy (không đóng được bằng click-outside) |
| Popover | Root/Trigger/Portal/Content | Khung nổi tùy ý gắn vào một trigger (không modal) |
| DropdownMenu | Root/Trigger/Portal/Content/Item/Separator | Menu lệnh điều hướng đầy đủ bàn phím |
| ContextMenu | giống Dropdown nhưng mở bằng chuột phải | Menu chuột phải |
| Tooltip | Provider/Root/Trigger/Portal/Content | Gợi ý ngắn hiện khi hover/focus |
| HoverCard | Root/Trigger/Portal/Content | Thẻ xem trước hiện khi hover (rich hơn tooltip) |
| Accordion | Root/Item/Header/Trigger/Content | Danh sách mục co/giãn được |
| Collapsible | Root/Trigger/Content | Một vùng đóng/mở đơn lẻ |
| Tabs | Root/List/Trigger/Content | Chuyển giữa các panel cùng cấp |
| Switch | Root/Thumb | Công tắc bật/tắt (boolean) |
| Checkbox | Root/Indicator | Ô chọn, hỗ trợ cả trạng thái indeterminate |
| RadioGroup | Root/Item/Indicator | Chọn một trong nhiều |
| Select | Root/Trigger/Value/Portal/Content/Item | Dropdown chọn giá trị, đầy đủ bàn phím |
| Slider | Root/Track/Range/Thumb | Thanh kéo chọn số (một hoặc nhiều thumb) |
| Toggle / ToggleGroup | Root (+ Item) | Nút nhấn-giữ-trạng-thái / nhóm nút |
| Toast | Provider/Root/Title/Description/Viewport | Thông báo nổi tạm thời, xếp chồng |
| Progress | Root/Indicator | Thanh tiến trình có ARIA |
| ScrollArea | Root/Viewport/Scrollbar/Thumb | Vùng cuộn với thanh cuộn tùy biến |
| Avatar | Root/Image/Fallback | Ảnh đại diện có fallback khi lỗi |
| NavigationMenu | Root/List/Item/Trigger/Content | Thanh điều hướng có submenu |
Mọi primitive trong bảng đều theo đúng khuôn
Root/.../Contentở Phần 4 và phơidata-stateở Phần 6. Học sâu một cái (Dialog) là đọc được tất cả.
8. DropdownMenu — đầy đủ bàn phím, style bằng Tailwind
Ví dụ thứ hai cho thấy data-[highlighted] thực chiến — móc style cho item đang active dù bằng chuột hay 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
data-[disabled]:opacity-40 data-[disabled]:pointer-events-none"
>
Profile <span className="text-xs text-muted-foreground">⌘P</span>
</DropdownMenu.Item>
<DropdownMenu.Item className="rounded px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent/15">
Settings
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-border" />
<DropdownMenu.Item className="rounded px-2 py-1.5 text-sm text-red-500 outline-none data-[highlighted]:bg-red-500/15">
Log out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
Bạn nhận miễn phí: phím ↑↓ điều hướng, typeahead (gõ “se” nhảy tới “Settings”), Home/End, Esc đóng, bấm ngoài đóng, tabindex luân chuyển (roving tabindex), và toàn bộ aria-*. Việc duy nhất của bạn là gắn class:
data-[highlighted]:bg-accent/15— nền item đang active.data-[disabled]:opacity-40— item bị vô hiệu mờ đi (vàpointer-events-noneđể khỏi bấm).data-[side=bottom]:slide-in-from-top-1— hướng animation theo phía menu bung ra.
9. Animation cần data-state — và một cái bẫy lớn
Đây là nơi nhiều người vấp. Bạn muốn dialog trượt vào khi mở và trượt ra khi đóng. Vấn đề: khi đóng, React bình thường sẽ gỡ component khỏi DOM ngay lập tức — không kịp chạy animation “ra”. Radix giải bằng cách giữ phần tử trong DOM cho đến khi animation kết thúc, và trong lúc đó đặt data-state="closed".
Nhờ vậy bạn có hai móc CSS cho hai chiều:
<Dialog.Content
className="
data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95
data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95
"
/>
Sơ đồ vòng đời để thấy vì sao cần data-state="closed":
mở: mount → data-state="open" → chạy animate-in (fade+zoom vào)
đóng: data-state="closed" → chạy animate-out (fade+zoom ra)
↓ (Radix CHỜ animation xong)
mới unmount khỏi DOM
Các utility animate-in / fade-in-0 / zoom-in-95 / slide-in-from-top-1 đến từ plugin tailwindcss-animate (hoặc tw-animate-css cho v4) — chính bộ bạn gặp ở Phần 6. Radix lo thời điểm, plugin lo chuyển động.
Bẫy: nếu bạn chỉ viết
data-[state=open]:fade-in-0mà quên cặpdata-[state=closed]:fade-out-0, dialog sẽ xuất hiện mượt nhưng biến mất phựt. Animation “ra” phải móc vàodata-state=closed, không phảiopen. Đây là lỗi animation hay gặp nhất với Radix.
10. Ghi chú khả năng tiếp cận (a11y)
Radix lo phần cơ chế a11y, nhưng vài thứ vẫn nằm ở phía bạn:
- Luôn cấp tên cho overlay component. Dialog cần
Dialog.Title(dù ẩn bằngVisuallyHidden), Tooltip/Item icon-only cầnaria-label. Không có tên, screen reader đọc rỗng. Radix sẽ cảnh báo trong console nếu Dialog thiếu Title. - Đừng phá focus ring. Bạn có thể đổi kiểu ring (
focus-visible:ring-2) nhưng đừngoutline-nonemà không thay bằng dấu hiệu focus khác — người dùng bàn phím cần thấy mình đang ở đâu. - Tôn trọng
prefers-reduced-motion. Animation mở/đóng nên dịu lại với người nhạy cảm chuyển động: bọc bằngmotion-reduce:animate-none(Phần 6). - Màu không phải tín hiệu duy nhất. Item “Log out” đỏ thì tốt, nhưng đừng chỉ dùng màu để báo trạng thái — kèm icon hoặc chữ.
data-disabled≠ chỉ làm mờ. Khi style item disabled, nhớ cảpointer-events-noneđể nó thật sự không bấm được, khớp với việc Radix đã bỏ nó khỏi luồng bàn phím.
Radix đưa bạn tới vạch a11y đúng từ đầu; nhiệm vụ của bạn là đừng vô tình phá nó khi style. Đó là một thỏa thuận rất hời.
11. Gotchas thường gặp
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Panel bị cha cắt mất / nằm dưới phần khác | Quên Portal, nên Content kẹt trong overflow:hidden / z-index của tổ tiên | Bọc Content trong <X.Portal> |
| Style “không ăn” vào nền mờ | Style nhầm Content trong khi cần style Overlay (và ngược lại) | Overlay = nền sau; Content = khung nổi. Style đúng phần |
| Dialog mở mượt, đóng “phựt” | Chỉ móc data-[state=open]:, thiếu data-[state=closed]:animate-out | Thêm cặp closed (Phần 9) |
| Console cảnh báo “missing Title/Description” | Dialog thiếu Dialog.Title | Thêm Title (ẩn bằng VisuallyHidden nếu không muốn hiện) |
| Item menu đổi màu khi hover chuột nhưng không khi dùng phím | Style bằng hover: thay vì data-[highlighted]: | Dùng data-[highlighted]: — nó phủ cả chuột và phím |
setOpen không có tác dụng | Đang ở chế độ uncontrolled mà lại cố điều khiển từ ngoài | Chuyển sang controlled: open + onOpenChange (Phần 5) |
Animation giật khi prefers-reduced-motion | Không tôn trọng cài đặt hệ điều hành | Thêm motion-reduce:animate-none |
| Trigger không nhận style của bạn / lồng nút trong nút | Muốn dùng phần tử của riêng mình làm trigger | Dùng asChild (Phần 8) để Radix “mượn” phần tử của bạn |
Ba gotcha đầu — quên Portal, nhầm Overlay vs Content, và animation thiếu
data-state=closed— chiếm phần lớn câu hỏi về Radix. Nhớ ba cái đó là tránh được hầu hết rắc rối.
12. Bài tập
1. Trong một câu, Radix cung cấp gì và cố tình không cung cấp gì?
Lời giải
Nó cung cấp hành vi, trạng thái, khả năng tiếp cận; cố tình không cung cấp style — bạn sở hữu toàn bộ vẻ ngoài qua Tailwind.
2. Vì sao Dialog.Content cần nằm trong Dialog.Portal?
Lời giải
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 và không bị cắt.
3. Variant Tailwind nào style một menu item của Radix khi nó đang “active” bằng phím hoặc chuột? Vì sao không dùng hover:?
Lời giải
data-[highlighted]:…. Vì hover: chỉ bắt chuột; Radix gắn data-highlighted cho item active dù người dùng đến bằng chuột hay phím mũi tên, nên nó phủ cả hai.
4. Một dialog mở thì hiện mượt nhưng khi đóng lại biến mất tức thì, không có animation “ra”. Thiếu gì?
Lời giải
Thiếu cặp data-[state=closed]: — ví dụ data-[state=closed]:animate-out data-[state=closed]:fade-out-0. Radix giữ phần tử trong DOM khi data-state="closed" đủ lâu để chạy animation ra; nếu không có class móc vào trạng thái đó thì chẳng có gì để chạy.
5. Bạn cần đóng một Popover từ bên ngoài sau khi một request fetch thành công. Dùng chế độ nào của Radix, và cặp prop nào?
Lời giải
Chế độ controlled: tự giữ state bằng useState, truyền open={open} onOpenChange={setOpen}, rồi gọi setOpen(false) trong callback .then(...) sau khi fetch xong.
6. Đặt tên đúng data-* mà Radix phơi cho: (a) một Switch đang bật, (b) hướng một Tooltip đang bung ra phía trên trigger.
Lời giải
(a) data-state="checked" → style bằng data-[state=checked]:…. (b) data-side="top" → style bằng data-[side=top]:….
Nâng cao: trong demo ở Phần 2, 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 (không lọt ra sau backdrop), và khi đóng bằng Esc thì focus trả về đúng trigger đã mở.
Điểm chính
- Headless = ship hành vi + a11y + cấu trúc DOM tối thiểu, không ship CSS — bạn sở hữu lớp vẻ ngoài bằng Tailwind.
- Radix tồn tại để giải 6 bài toán khó đúng một lần: focus management, focus trap, keyboard nav, ARIA wiring, portal/layering, dismiss layer.
- Mọi primitive theo mẫu compound
Root/Trigger/Portal/Content/Item, ngầm chia sẻ state qua Context — bạn lắp như Lego và tự gắnclassName. - State chạy hai chế độ: uncontrolled (mặc định, ít code) và controlled (
open+onOpenChange,value+onValueChange…) khi cần điều khiển từ ngoài. - Hợp đồng style là thuộc tính
data-*(data-state,data-highlighted,data-side…) ↔ variantdata-[…]:của Tailwind — CSS phản ứng theo trạng thái, không cần JS. - Animation cần cả hai chiều:
data-[state=open]:animate-invàdata-[state=closed]:animate-out; Radix giữ DOM đủ lâu để chạy animation “ra”. - Đừng vô tình phá a11y khi style: giữ tên cho overlay, giữ dấu hiệu focus, tôn trọng
prefers-reduced-motion.
Tiếp theo
Phần 8 — Radix chuyên sâu: state controlled vs uncontrolled ở mức nâng cao, mẫu asChild (ghép hành vi Radix lên phần tử của bạn để tránh lồng nút), 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.