Tailwind, Radix & shadcn/ui · Part 8 — Radix Deep: Composition, asChild & State
Go pro with Radix: controlled vs uncontrolled state, the asChild slot pattern to merge behavior onto your own components, and Tabs/Switch/Tooltip/Popover composition — the toolkit before shadcn ties it together. With a live composition lab.
Phần 7 đã mổ xẻ giải phẫu một Radix primitive: Root/Trigger/Content, portal, và thuộc tính data-state. Đó là biết tên các bộ phận. Phần này là biết lắp ráp — ba kỹ năng tách “tôi copy nguyên ví dụ trong docs Radix” khỏi “tôi nhồi Radix vào design system của mình”:
- State — controlled vs uncontrolled, đến tận cơ chế: khi nào Radix tự giữ state, khi nào bạn cầm lái, và bảng quy ước prop của mọi primitive.
asChild+Slot— cách Radix gộp hành vi (props, ref, event handler) vào component của chính bạn thay vì render thêm một thẻ DOM.- Composition đa primitive — Tooltip bọc Dialog trigger, một item trong DropdownMenu mở ra Dialog, và chiến lược style để không lặp lại
data-[state=…]ở mọi nơi.
Ba hợp đồng không được phá
Radix rất linh hoạt, nhưng composition tốt dựa trên ba hợp đồng:
| Hợp đồng | Nghĩa là | Nếu phá thì lỗi gì |
|---|---|---|
| Controlled đi theo cặp | open luôn đi với onOpenChange, value đi với onValueChange | popup kẹt mở/đóng, tab không đổi |
asChild cần một child nhận prop/ref | child phải forward className, event handler, ref | trigger không focus được, click không chạy, style mất |
| State style đọc từ DOM | dùng data-state, aria-*, data-disabled | UI lệch accessibility, test khó đọc |
Khi một composition Radix “tự nhiên hỏng”, 90% thời gian là một trong ba hợp đồng này bị vi phạm. Đọc phần này như một checklist debug, không chỉ như API reference.
Thử Tabs, Switch, Tooltip, Popover trực tiếp khi ta đi:
1. Uncontrolled vs controlled — toàn cảnh
Đây là khái niệm React nền tảng mà Radix kế thừa nguyên xi, nên nắm chắc một lần là dùng được cho mọi primitive lẫn cho <input> thường.
Uncontrolled = component tự giữ state bên trong (qua useState của riêng nó). Bạn chỉ đưa giá trị khởi tạo qua prop defaultX, rồi để mặc kệ. Radix nhớ “đang mở hay đóng”, “đang checked hay không” — bạn không cần biến state nào cả.
Controlled = bạn giữ state (trong useState/store của bạn) và bơm ngược vào component qua prop x, đồng thời nghe sự kiện đổi qua onXChange. Component không còn trí nhớ riêng — nó hiển thị đúng cái bạn truyền vào.
UNCONTROLLED CONTROLLED
──────────── ──────────
defaultOpen={false} open={open} onOpenChange={setOpen}
┌─────────────┐ ┌─────────────┐
│ Radix │ state sống │ Radix │ state sống
│ (state nội)│ TRONG Radix │ (không nhớ) │ TRONG component bạn
└─────────────┘ └──────┬──────┘
▲ │ onOpenChange(next)
│ bạn chỉ set lần đầu ▼
(không đọc lại được) const [open,setOpen]=useState()
│ open=...
└──► chảy ngược vào Radix
Vì sao có hai chế độ?
Chế độ uncontrolled tồn tại để code gọn: 90% trường hợp bạn không quan tâm dialog đang mở hay đóng, chỉ cần nó mở khi bấm trigger và đóng khi bấm ra ngoài. Tự quản state cho mấy ca đó là boilerplate thừa.
Chế độ controlled tồn tại khi state cần sống ở chỗ khác ngoài component: bạn muốn mở dialog từ một nút ở tận đẩu đâu, đồng bộ tab đang mở vào URL, đóng popover sau khi fetch xong, hay chặn không cho đóng khi form còn dở.
// UNCONTROLLED — Radix tự nhớ, bạn quên đi
<Dialog.Root defaultOpen={false}>…</Dialog.Root>
<Switch.Root defaultChecked />
<Tabs.Root defaultValue="account">…</Tabs.Root>
// CONTROLLED — bạn cầm state, mở/đọc từ bất cứ đâu
const [open, setOpen] = useState(false);
<Dialog.Root open={open} onOpenChange={setOpen}>…</Dialog.Root>
// giờ mở được từ một nút bất kỳ ngoài cây dialog:
<button onClick={() => setOpen(true)}>Open from outside</button>
Cái bẫy “controlled một nửa”
Lỗi kinh điển: truyền open (controlled) nhưng quên onOpenChange. Khi đó Radix gọi handler để báo “người dùng vừa bấm ra ngoài, hãy đóng đi” — nhưng không có ai nghe, nên state open của bạn không bao giờ đổi, và dialog không đóng được. Quy tắc: đã đưa open thì phải đưa onOpenChange; đã đưa value thì phải đưa onValueChange. Cặp đôi đi liền.
// ❌ controlled một nửa — dialog kẹt mở, đóng không được
<Dialog.Root open={open}>…</Dialog.Root>
// ✅ luôn đi theo cặp prop + handler
<Dialog.Root open={open} onOpenChange={setOpen}>…</Dialog.Root>
Đừng trộn
defaultOpenvớiopen. Một primitive hoặc uncontrolled (defaultOpen) hoặc controlled (open) — không phải cả hai. Truyền cùng lúc, React sẽ cảnh báo và hành vi thành khó đoán.
Bảng quy ước prop — học một, áp tất
Mọi Radix primitive đặt tên prop theo đúng một công thức: defaultX (uncontrolled) / x + onXChange (controlled). Chỉ tên X đổi theo primitive:
| Primitive | State là gì | Uncontrolled | Controlled (value) | Controlled (handler) |
|---|---|---|---|---|
Dialog / AlertDialog | mở/đóng | defaultOpen | open | onOpenChange |
Popover / HoverCard | mở/đóng | defaultOpen | open | onOpenChange |
DropdownMenu / ContextMenu | mở/đóng | defaultOpen | open | onOpenChange |
Tooltip | mở/đóng | defaultOpen | open | onOpenChange |
Collapsible / Accordion* | mở/đóng | defaultOpen | open | onOpenChange |
Tabs | tab đang chọn | defaultValue | value | onValueChange |
RadioGroup / ToggleGroup | mục đang chọn | defaultValue | value | onValueChange |
Select | mục đang chọn | defaultValue | value | onValueChange |
Switch / Checkbox | bật/tắt | defaultChecked | checked | onCheckedChange |
Toggle | bật/tắt | defaultPressed | pressed | onPressedChange |
Slider / Progress | số (mảng) | defaultValue | value | onValueChange |
*
Accordiondùngvalue/defaultValue(vì có thể nhiều panel mở). Nhìn chung: state nhị phân (mở, checked) dùngopen/checked; state chọn-một-trong-nhiều dùngvalue. Biết quy tắc này thì bạn không cần tra docs từng primitive nữa.
onXChange luôn nhận giá trị tiếp theo làm tham số đầu — onOpenChange(nextOpen: boolean), onValueChange(nextValue: string) — nên bạn truyền thẳng setState vào được: onOpenChange={setOpen}.
2. asChild — vấn đề nó giải quyết
Mặc định Dialog.Trigger render ra một <button> thật. Nhưng bạn đã có component Button style sẵn (từ Phần 5). Nếu cứ đặt Button bên trong Trigger, bạn được… một <button> lồng trong <button> — HTML không hợp lệ, và hai lớp style đánh nhau:
// ❌ button trong button — HTML sai, style chồng chéo
<Dialog.Trigger>
<Button variant="outline">Edit profile</Button>
</Dialog.Trigger>
// render ra: <button …><button class="…">Edit profile</button></button>
asChild nói với Radix: đừng render thẻ của bạn nữa; thay vào đó hãy lấy tất cả hành vi (props, ref, event handler) gắn thẳng vào đứa con tôi đưa cho bạn:
import { Button } from '@/components/ui/button';
// ✅ asChild — không thẻ thừa, Button của bạn TRỞ THÀNH trigger
<Dialog.Trigger asChild>
<Button variant="outline">Edit profile</Button>
</Dialog.Trigger>
// render ra: <button class="…(style Button của bạn)…"
// aria-haspopup="dialog" aria-expanded="false"
// data-state="closed" onclick="(mở dialog)">Edit profile</button>
Kết quả: một phần tử DOM duy nhất, mang style Button của bạn nhưng cõng toàn bộ onClick, aria-haspopup, aria-expanded, ref và data-state của Radix. Đây chính là khớp nối khiến Radix, shadcn và design system của bạn ăn vào nhau mượt mà.
Slot — cơ chế bên dưới
asChild chạy được nhờ một component nội bộ tên Slot (@radix-ui/react-slot). Khi asChild bật, Radix render <Slot {...props}>{children}</Slot> thay vì <button {...props}>. Slot không tự vẽ DOM — nó nhận props của nó, nhận đứa con duy nhất, rồi trộn props của hai bên rồi clone đứa con với props đã trộn:
<Dialog.Trigger asChild> props Radix: { onClick, aria-*, data-state, ref }
<Button onClick={myFn}>… props của bạn: { onClick: myFn, className, … }
</Dialog.Trigger>
│
▼ Slot.merge(propsRadix, propsButton)
│
▼
<button ← thẻ THẬT do Button render
className="(của bạn)" ← className giữ nguyên
aria-haspopup aria-expanded ← props Radix gắn vào
data-state="closed"
ref={mergedRef} ← ref của cả hai được hợp nhất
onClick={chain(radixOnClick, myFn)}> ← hai handler nối chuỗi (xem mục 4)
…
</button>
Slot chính là thứ bạn dùng trực tiếp để khiến component của mình đa hình (polymorphic) — tự nó cũng hỗ trợ asChild. Mẫu “nếu asChild thì là Slot, ngược lại là thẻ thường” là xương sống của mọi component shadcn:
import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/lib/utils';
function Button({ asChild = false, className, ...props }) {
const Comp = asChild ? Slot : 'button'; // ← chọn "vật chứa"
return <Comp className={cn(buttonVariants(), className)} {...props} />;
}
// dùng như button thường:
<Button>Lưu</Button>
// HOẶC mượn style Button cho một thẻ khác (mục 8):
<Button asChild><a href="/docs">Tới docs</a></Button>
forwardRef — vì sao bắt buộc
Slot cần gắn ref của Radix vào node DOM thật. Nhưng ref không chảy xuyên qua function component theo cơ chế thường — nó dừng lại ở ranh giới component. Nên đứa con của asChild phải biết forward ref xuống thẻ DOM của nó:
// ✅ React 19 — ref là prop bình thường, forward thẳng
function Button({ className, ref, ...props }) {
return <button ref={ref} className={cn(buttonVariants(), className)} {...props} />;
}
// ✅ React ≤18 — phải bọc forwardRef
const Button = React.forwardRef(({ className, ...props }, ref) => (
<button ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
Button.displayName = 'Button';
Quên forward ref thì Radix không nắm được node DOM → mất khả năng quản lý focus, định vị popover, phát hiện bấm-ngoài. Tệ hơn: nó hỏng âm thầm — không lỗi đỏ, chỉ là hành vi biến mất.
3. Đúng một đứa con — và vì sao
Slot clone một phần tử và trộn props vào đó. Nếu bạn đưa hai con, nó không biết gắn onClick vào đứa nào → Radix ném lỗi runtime ngay:
// ❌ asChild với HAI con — lỗi: "only expected to receive a single React element child"
<Dialog.Trigger asChild>
<Icon />
<span>Edit</span>
</Dialog.Trigger>
Cách sửa: gói lại thành một phần tử (đứa con đó tự chứa nhiều thứ bên trong):
// ✅ một phần tử bọc ngoài — hợp lệ
<Dialog.Trigger asChild>
<Button>
<Icon />
<span>Edit</span>
</Button>
</Dialog.Trigger>
Nếu thật sự cần render nhiều node ngang hàng mà vẫn dùng asChild, Radix có Slottable để đánh dấu đâu là node nhận props — nhưng 99% trường hợp, “bọc thành một phần tử” là câu trả lời đúng.
4. Nối chuỗi event handler
Câu hỏi tinh tế: nếu cả Radix và bạn đều muốn onClick thì sao? Ví dụ Radix cần onClick để mở dialog, còn bạn muốn onClick để log analytics. Slot không vứt cái nào — nó nối chuỗi: chạy handler của Radix trước, rồi handler của bạn (trừ khi handler trước gọi preventDefault).
<Dialog.Trigger asChild>
<Button onClick={() => track('open_profile')}>
Edit profile
</Button>
</Dialog.Trigger>
// bấm → Radix mở dialog → SAU ĐÓ track('open_profile') chạy
Hệ quả thực dụng: muốn chặn hành vi Radix, gọi event.preventDefault() trong handler của bạn. Đây là cách giữ một menu mở sau khi bấm item, hay xác nhận trước khi đóng:
<AlertDialog.Cancel asChild>
<Button
onClick={(e) => {
if (hasUnsavedChanges) e.preventDefault(); // chặn đóng nếu form dở
}}
>
Cancel
</Button>
</AlertDialog.Cancel>
Thứ tự: với hầu hết primitive Radix, handler nội bộ chạy trước rồi mới tới handler của bạn. Nên muốn phủ quyết hành vi mặc định, dùng
preventDefault()— chứ đừng trông vào việc handler của bạn chạy trước.
5. Bảng gotcha asChild
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
Error: only expected … single React element child | Đưa nhiều con cho asChild | Bọc lại thành một phần tử bao ngoài (mục 3) |
| Focus/định vị/bấm-ngoài không chạy | Con không forward ref | forwardRef (React ≤18) hoặc nhận ref làm prop (React 19) rồi gắn vào thẻ DOM |
Style biến mất khi gắn asChild | className của Radix đè/không gộp className của con | Component con phải spread {...props} và dùng cn() để gộp className |
onClick của bạn không chạy | Handler Radix gọi preventDefault chặn chuỗi, hoặc con không spread {...props} | Spread {...props}; nếu cần phủ quyết, tự gọi preventDefault trong handler của bạn |
data-state không xuất hiện trên phần tử | Con nuốt props, không spread xuống thẻ DOM | Đảm bảo {...props} rơi tới đúng thẻ DOM gốc |
Render hai thẻ lồng nhau dù đã asChild | Con cũng là một wrapper render thẻ riêng | Con phải render đúng một thẻ DOM gốc và forward props/ref vào đó |
| Click xuyên hai lớp / kích hoạt hai lần | Vừa để asChild vừa lồng thêm <button> bên trong con | Bỏ thẻ tương tác lồng; chỉ giữ một phần tử tương tác |
6. Composition đa primitive
Radix được thiết kế để lồng vào nhau. Hai mẫu hay gặp nhất:
Tooltip bọc một Dialog trigger
Bạn muốn một nút icon: hover hiện tooltip “Chỉnh sửa”, bấm thì mở dialog. Cả Tooltip.Trigger lẫn Dialog.Trigger đều muốn là cùng một nút — asChild cho phép chồng chúng lên nhau:
<Tooltip.Provider delayDuration={200}>
<Dialog.Root>
<Tooltip.Root>
{/* Dialog.Trigger asChild → Tooltip.Trigger asChild → Button thật */}
<Tooltip.Trigger asChild>
<Dialog.Trigger asChild>
<Button variant="ghost" size="icon" aria-label="Edit">
<PencilIcon className="size-4" />
</Button>
</Dialog.Trigger>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="rounded bg-surface px-2 py-1 text-xs">
Chỉnh sửa
<Tooltip.Arrow className="fill-surface" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
rounded-xl bg-surface p-6 shadow-lg">
<Dialog.Title>Chỉnh sửa hồ sơ</Dialog.Title>
…
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</Tooltip.Provider>
Vì cả hai trigger asChild xuống cùng Button, Slot gộp props của cả hai primitive vào một thẻ: nó vừa mang aria của tooltip, vừa mang aria-haspopup="dialog" và onClick mở dialog. Một nút, hai hành vi.
Item trong DropdownMenu mở ra một Dialog
Đây là ca khó kinh điển, vì hai primitive cãi nhau về focus: khi DropdownMenu.Item được bấm, menu đóng và trả focus về trigger — nhưng bạn lại muốn nó mở một dialog. Nếu để menu tự đóng theo cách thường, dialog vừa mở đã bị giật focus, hỏng. Mẫu đúng: nâng state dialog lên controlled, và chặn auto-focus của menu khi đóng.
function RowActions() {
const [open, setOpen] = useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button variant="ghost" size="icon"><DotsIcon /></Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
// đừng giật focus về trigger khi menu đóng để mở dialog
onCloseAutoFocus={(e) => e.preventDefault()}
className="min-w-40 rounded-md border bg-surface p-1 shadow-md"
>
<DropdownMenu.Item className="rounded px-2 py-1.5 text-sm
data-[highlighted]:bg-muted">
Sao chép
</DropdownMenu.Item>
{/* item này KHÔNG render dialog trực tiếp — chỉ set state mở */}
<DropdownMenu.Item
onSelect={(e) => {
e.preventDefault(); // chặn menu đóng theo kiểu mặc định
setOpen(true); // tự tay mở dialog
}}
className="rounded px-2 py-1.5 text-sm text-red-600
data-[highlighted]:bg-red-50"
>
Xoá…
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
rounded-xl bg-surface p-6 shadow-lg">
<Dialog.Title>Xoá mục này?</Dialog.Title>
…
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Chìa khoá: Dialog nằm ngoài
DropdownMenu(anh em, không phải con), state mở của nó là controlled, vàItemdùngonSelect+preventDefaultđể menu không tự “nuốt” hành động. Đây là khác biệt giữa demo và code production.
7. Chiến lược style tái sử dụng
Viết lại nguyên chuỗi data-[state=…] ở mỗi lần dùng không scale. Nước đi pro: bọc mỗi primitive đúng một lần với style nung sẵn, phơi ra API gọn — phần còn lại của app chỉ import wrapper:
// components/ui/switch.tsx — bọc một lần, dùng khắp nơi
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
export function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
className={cn(
'h-6 w-11 rounded-full bg-input transition-colors',
'data-[state=checked]:bg-primary',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
className="block size-5 rounded-full bg-white shadow transition-transform
data-[state=checked]:translate-x-5"
/>
</SwitchPrimitive.Root>
);
}
Ba điểm khiến đây là wrapper “đúng chuẩn”, không chỉ là copy:
React.ComponentProps<typeof …Root>— kế thừa nguyên kiểu props của Radix, nênchecked,onCheckedChange,disabled… vẫn truyền xuyên qua mà không cần khai báo lại.cn(... , className)— style mặc định đứng trước,classNamecủa người dùng đứng sau để override được (Phần 5 giải thích vì sao thứ tự này, cùngtailwind-merge).- Spread
{...props}— mọi prop chưa nắm (kể cảdata-*,aria-*, event handler) chảy thẳng xuống Radix.
Khi thấy bạn lặp đúng mẫu wrapper này qua hàng chục primitive — đó chính xác là vấn đề shadcn/ui giải quyết: nó là bộ wrapper này, tuyển chọn sẵn, copy thẳng vào repo bạn (Phần 9).
8. Animation lái bằng data-state
Vì Radix dán data-state lên phần tử ở mỗi trạng thái, bạn animate hoàn toàn bằng CSS/Tailwind — không cần thư viện animation, không cần useState. Mấu chốt: phần tử vẫn ở trong DOM trong lúc đóng (Radix giữ nó lại tới khi animation xong nhờ cơ chế “presence”), nên data-[state=closed] có thời gian chạy.
<Dialog.Content
className="
fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
rounded-xl bg-surface p-6 shadow-lg
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
"
>
…
</Dialog.Content>
Các data-state bạn sẽ nhắm thường xuyên:
| Selector Tailwind | Khi nào active | Dùng cho |
|---|---|---|
data-[state=open]: | overlay/menu/popover đang mở | fade-in, zoom-in, slide-in |
data-[state=closed]: | đang đóng (còn trong DOM lúc thoát) | fade-out, zoom-out, slide-out |
data-[state=checked]: | Switch/Checkbox bật | đổi nền, trượt thumb |
data-[state=active]: | Tabs.Trigger đang chọn | nền/chữ tab active |
data-[state=delayed-open]: | Tooltip mở sau delay | fade-in dịu cho tooltip |
data-[highlighted] | item menu đang được hover/phím | nền highlight item |
data-[disabled] | phần tử bị disable | giảm opacity, đổi con trỏ |
Nếu dùng plugin tailwindcss-animate (shadcn cài sẵn), bạn có ngay animate-in/animate-out cùng các tiện ích fade-*, zoom-*, slide-in-from-*. Không có plugin thì tự viết @keyframes rồi nhắm bằng cùng selector data-[state=…] — cơ chế y hệt.
/* tự cuốn, không cần plugin */
@keyframes overlayIn { from { opacity: 0 } to { opacity: 1 } }
[data-state='open'] > .overlay { animation: overlayIn 150ms ease-out; }
Vì sao animate đóng được: nếu Radix gỡ node ngay khi đóng,
data-[state=closed]không kịp chạy. Radix giữ node lại đến khi animation kết thúc (theo dõianimationend). Đó là lý do bạn không cầnAnimatePresencecủa framer — Radix đã tự lo “presence”.
9. Tổng kết hai primitive style sẵn
Gộp các mảnh trên, đây là Tabs và Tooltip ở dạng “production”, đủ state + style + a11y:
Tabs — data-[state=active] cho trigger:
<Tabs.Root defaultValue="account" className="w-80">
<Tabs.List className="flex gap-1 rounded-lg bg-muted p-1">
<Tabs.Trigger
value="account"
className="flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors
data-[state=active]:bg-indigo-500 data-[state=active]:text-white
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Account
</Tabs.Trigger>
<Tabs.Trigger value="password" className="flex-1 rounded-md px-3 py-1.5 text-sm font-medium
data-[state=active]:bg-indigo-500 data-[state=active]:text-white">
Password
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account" className="pt-4 text-sm">Thông tin tài khoản…</Tabs.Content>
<Tabs.Content value="password" className="pt-4 text-sm">Đổi mật khẩu…</Tabs.Content>
</Tabs.Root>
Tooltip — cần một Tooltip.Provider gần gốc app; mở khi hover và focus bàn phím:
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant="ghost" size="icon" aria-label="Lưu"><SaveIcon /></Button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
sideOffset={6}
className="rounded bg-surface px-2 py-1 text-xs shadow
data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0"
>
Đã lưu 2 phút trước
<Tooltip.Arrow className="fill-surface" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
Popover giống Dialog nhưng không modal (không khoá nền) — lý tưởng cho editor, color picker, filter inline. Cùng bộ khung Root/Trigger/Portal/Content, cùng cặp prop open/onOpenChange.
10. Recipe thực tế
Recipe 1 — Button asChild làm link
Bạn muốn một thẻ <a> trông y hệt một nút primary nhưng vẫn là link thật (điều hướng, mở tab mới, được crawler hiểu). asChild mượn style của Button cho <a>:
<Button asChild>
<a href="/pricing">Xem bảng giá</a>
</Button>
// render: <a href="/pricing" class="(toàn bộ style Button)…">Xem bảng giá</a>
Với router (Next.js / React Router), bọc đúng component link của router:
<Button asChild variant="outline">
<Link href="/dashboard">Tới dashboard</Link>
</Button>
Nhờ vậy bạn không bao giờ phải dán lại buttonVariants() lên một <a> thủ công — một nguồn style duy nhất cho cả nút lẫn link.
Recipe 2 — menu → dialog xác nhận xoá
Gói lại mẫu ở mục 6 thành một component tái dùng được, có cả nút xác nhận:
function DeleteRowMenu({ onConfirm }: { onConfirm: () => void }) {
const [open, setOpen] = useState(false);
return (
<AlertDialog.Root open={open} onOpenChange={setOpen}>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button variant="ghost" size="icon" aria-label="Hành động"><DotsIcon /></Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
onCloseAutoFocus={(e) => e.preventDefault()}
className="min-w-40 rounded-md border bg-surface p-1 shadow-md"
>
<DropdownMenu.Item
onSelect={(e) => { e.preventDefault(); setOpen(true); }}
className="rounded px-2 py-1.5 text-sm text-red-600 data-[highlighted]:bg-red-50"
>
Xoá…
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 bg-black/50
data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<AlertDialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
rounded-xl bg-surface p-6 shadow-lg">
<AlertDialog.Title className="text-lg font-semibold">Xoá mục này?</AlertDialog.Title>
<AlertDialog.Description className="mt-1 text-sm text-muted-foreground">
Hành động không thể hoàn tác.
</AlertDialog.Description>
<div className="mt-4 flex justify-end gap-2">
<AlertDialog.Cancel asChild>
<Button variant="outline">Huỷ</Button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<Button variant="destructive" onClick={onConfirm}>Xoá</Button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
}
Để ý mọi mảnh ghép của bài này hội tụ: controlled state (open/onOpenChange), asChild để mượn Button cho mọi nút của AlertDialog, onSelect + preventDefault để menu không nuốt hành động, và data-[state=open] cho animation vào.
11. Bài tập
1. Khi nào một component Radix nên controlled thay vì uncontrolled?
Lời giải
Khi state cần sống ngoài component: mở/đọc bằng code, đồng bộ URL hay store, chạy side effect khi đổi, hoặc chặn việc đóng theo điều kiện. Còn lại, để uncontrolled cho gọn.
2. Vì sao đứa con của asChild phải forward ref?
Lời giải
Slot cần gắn ref của Radix vào node DOM thật để Radix quản lý focus, định vị, và phát hiện bấm-ngoài. ref không tự chảy qua function component, nên con phải forwardRef (React ≤18) hoặc nhận ref làm prop (React 19) rồi gắn vào thẻ. Không forward thì hành vi hỏng âm thầm, không báo lỗi.
3. Style một Tabs.Trigger để tab active có nền indigo và chữ trắng.
Lời giải
<Tabs.Trigger value="x"
className="data-[state=active]:bg-indigo-500 data-[state=active]:text-white">X</Tabs.Trigger>Radix dán data-state="active" lên trigger đang chọn; selector data-[state=active]: chỉ ăn vào lúc đó.
4. Đoạn này render ra gì, và sai ở đâu?
<Dialog.Trigger asChild>
<Icon />
<span>Edit</span>
</Dialog.Trigger>
Lời giải
Lỗi runtime: asChild/Slot chỉ nhận đúng một phần tử con (nó clone một node để trộn props). Có hai con thì nó không biết gắn onClick/ref vào đâu. Sửa: bọc cả hai vào một phần tử, ví dụ <Button><Icon /><span>Edit</span></Button>.
5. Bạn truyền open={open} cho Dialog.Root nhưng dialog bấm ra ngoài không đóng. Thiếu gì?
Lời giải
Thiếu onOpenChange={setOpen}. Khi controlled, Radix không tự đổi state — nó gọi onOpenChange(false) để báo bạn nên đóng. Không nghe sự kiện đó thì open không bao giờ về false. Controlled là một cặp: open và onOpenChange luôn đi cùng nhau.
6. Trong mẫu “menu item mở dialog”, vì sao phải gọi e.preventDefault() trong onSelect của DropdownMenu.Item?
Lời giải
Mặc định khi chọn một Item, menu tự đóng và giật focus về trigger. Nếu để vậy, dialog vừa mở đã bị cướp focus → vỡ. e.preventDefault() chặn hành vi đóng-mặc-định để bạn tự kiểm soát: set open của dialog, và (kèm onCloseAutoFocus preventDefault trên Content) giữ focus cho dialog.
Nâng cao: trong lab, bật Switch rồi mở Popover và để ý cả hai phơi data-state — cùng một loại móc bạn nhắm bằng data-[state=checked]: và data-[state=open]:. Thử thêm data-[state=open]:zoom-in-95 cho Popover content xem animation vào ra sao.
Điểm chính
- Uncontrolled (
defaultX) cho Radix tự giữ state — mặc định, gọn nhất. Controlled (x+onXChange) khi state cần sống ngoài component. Đã đưaxthì bắt buộc đưaonXChange, nếu không primitive kẹt. - Mọi primitive theo một quy ước prop: state nhị phân →
open/checked, chọn-một →value; handler luôn nhận giá trị tiếp theo. Học một bảng, áp cho tất. asChild(quaSlot) gộp props + ref + event handler của Radix vào phần tử của bạn — không thẻ DOM thừa. Yêu cầu: đúng một con, con phải forwardrefvà spread{...props}.Slotnối chuỗi event handler (Radix trước, bạn sau); gọipreventDefault()để phủ quyết hành vi Radix — nền tảng cho mẫu menu→dialog.- Compose đa primitive: chồng nhiều
asChildlên cùng một nút (Tooltip + Dialog trigger); với menu→dialog, để Dialog controlled, đặt ngoài menu, dùngonSelect+preventDefault. data-[state=…]cho phép animate vào/ra thuần CSS — Radix giữ node tới khi animation xong nêndata-[state=closed]kịp chạy.- Bọc mỗi primitive một lần (
ComponentProps+cn+ spread) là mẫu scale được — và đúng thứ shadcn/ui sản phẩm hoá.
Tiếp theo
Phần 9 — shadcn/ui: triết lý (“không phải thư viện component”), CLI, components.json, và thêm component đầu tiên — nơi Tailwind + Radix + cva/cn cuối cùng khớp vào một workflow.