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.
Part 7 covered the anatomy {Phần 7 đã nói về giải phẫu}. Now the patterns that separate “I copied a Radix example” from “I can compose Radix into my own design system” {Giờ là các mẫu phân biệt “tôi copy ví dụ Radix” với “tôi kết hợp Radix vào design system của mình”}: controlled state, the crucial asChild slot, and the composition vocabulary across more primitives {state controlled, slot asChild then chốt, và vốn từ kết hợp qua nhiều primitive}.
Try Tabs, Switch, Tooltip, and Popover live as we go {Thử Tabs, Switch, Tooltip, Popover trực tiếp khi ta đi}:
1. Uncontrolled vs controlled {Uncontrolled vs controlled}
By default Radix is uncontrolled — it owns the open/closed (or checked) state internally {Mặc định Radix là uncontrolled — nó tự giữ trạng thái mở/đóng (hay checked) bên trong}. You set an initial value and forget it {Bạn đặt giá trị ban đầu rồi quên đi}:
<Dialog.Root defaultOpen={false}>…</Dialog.Root>
<Switch.Root defaultChecked />
Switch to controlled when you need to drive or observe the state — to open a dialog from elsewhere, sync to a URL, or run side effects {Chuyển sang controlled khi bạn cần điều khiển hoặc theo dõi trạng thái — mở dialog từ nơi khác, đồng bộ URL, hay chạy side effect}:
const [open, setOpen] = useState(false);
<Dialog.Root open={open} onOpenChange={setOpen}>
…
</Dialog.Root>
// now you can do this from anywhere:
<button onClick={() => setOpen(true)}>Open from outside</button>
The rule {Quy tắc}: uncontrolled by default; reach for controlled only when you have a concrete reason {mặc định uncontrolled; chỉ dùng controlled khi có lý do cụ thể}. Every Radix primitive follows the same value/onValueChange, open/onOpenChange, checked/onCheckedChange convention {Mọi Radix primitive theo cùng quy ước value/onValueChange, open/onOpenChange, checked/onCheckedChange}.
2. asChild — the slot pattern {asChild — mẫu slot}
By default Dialog.Trigger renders a <button> {Mặc định Dialog.Trigger render một <button>}. But you already have a styled Button component (from Part 5) — you don’t want a button inside a button {Nhưng bạn đã có component Button đã style (từ Phần 5) — bạn không muốn một button trong button}. asChild tells Radix: don’t render your own element; merge your behavior and props onto my child instead {asChild bảo Radix: đừng render phần tử của bạn; hãy ghép hành vi và props vào con của tôi}:
import { Button } from '@/components/ui/button';
<Dialog.Trigger asChild>
<Button variant="outline">Edit profile</Button>
</Dialog.Trigger>
Now there’s no extra wrapper DOM — Radix’s onClick, aria-haspopup, aria-expanded, ref, and data-state all land on your Button {Giờ không có DOM bọc thừa — onClick, aria-haspopup, aria-expanded, ref và data-state của Radix đều rơi vào Button của bạn}. This is how Radix, shadcn, and your design system interlock cleanly {Đây là cách Radix, shadcn và design system của bạn khớp vào nhau gọn gàng}.
The gotcha: an
asChildchild must accept and forward arefand spread props {Điểm vướng: con củaasChildphải nhận và forwardrefcùng spread props}. With function components, that meansReact.forwardRef(or a ref-as-prop setup) and{...props}— otherwise the merged behavior silently drops {Với function component, nghĩa làReact.forwardRef(hoặc ref-as-prop) và{...props}— nếu không, hành vi ghép vào sẽ âm thầm rơi mất}.
Under the hood asChild is powered by Radix’s Slot component, which you can use directly to build your own polymorphic, asChild-capable components {Bên dưới asChild chạy nhờ component Slot của Radix, thứ bạn dùng trực tiếp để dựng component của mình có khả năng đa hình, hỗ trợ asChild}:
import { Slot } from '@radix-ui/react-slot';
function Button({ asChild, className, ...props }) {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants(), className)} {...props} />;
}
This single trick — Slot when asChild, else the tag — is exactly how shadcn’s Button supports asChild {Mẹo này — Slot khi asChild, ngược lại là thẻ — chính là cách Button của shadcn hỗ trợ asChild}.
3. Composition across primitives {Kết hợp qua các primitive}
All Radix primitives share the same mental model, so once you know one, you know them all {Mọi Radix primitive chung một mô hình tư duy, nên biết một là biết tất}.
Tabs — value/onValueChange, items expose data-state="active" {Tabs}:
<Tabs.Root defaultValue="account">
<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
data-[state=active]:bg-indigo-500 data-[state=active]:text-white">
Account
</Tabs.Trigger>
<Tabs.Trigger value="password" className="…">Password</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">…</Tabs.Content>
<Tabs.Content value="password">…</Tabs.Content>
</Tabs.Root>
Switch — data-state="checked"; style track and thumb {Switch}:
<Switch.Root className="h-6 w-11 rounded-full bg-input data-[state=checked]:bg-indigo-500">
<Switch.Thumb className="block size-5 rounded-full bg-white transition-transform
data-[state=checked]:translate-x-5" />
</Switch.Root>
Tooltip — needs a single Tooltip.Provider near the app root; opens on hover and focus {Tooltip — cần một Tooltip.Provider gần gốc app; mở khi hover và focus}:
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild><Button>Save</Button></Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="rounded bg-surface px-2 py-1 text-xs
data-[state=delayed-open]:animate-in">
Saved 2 min ago
<Tooltip.Arrow className="fill-surface" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
Popover — like Dialog but non-modal; great for inline editors and pickers {Popover — như Dialog nhưng không modal; tốt cho editor và picker inline}. Same Root/Trigger/Portal/Content shape {Cùng hình Root/Trigger/Portal/Content}.
4. A reusable styling strategy {Chiến lược style tái sử dụng}
Writing the same data-[state=…] classes on every usage doesn’t scale {Viết lại cùng class data-[state=…] ở mỗi lần dùng không scale}. The pro move is to wrap each primitive once with your styles baked in, exposing a clean API {Nước đi pro là bọc mỗi primitive một lần với style đã nung sẵn, phơi ra API gọn}:
// components/ui/switch.tsx — wrap once, use everywhere
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 data-[state=checked]:bg-primary', className)}
{...props}
>
<SwitchPrimitive.Thumb className="block size-5 rounded-full bg-white transition-transform data-[state=checked]:translate-x-5" />
</SwitchPrimitive.Root>
);
}
If this feels like a lot of boilerplate across dozens of components — that’s the exact problem shadcn/ui solves {Nếu thấy đây là nhiều boilerplate qua hàng chục component — đó chính xác là vấn đề shadcn/ui giải quyết}. shadcn is a curated set of these wrappers, pre-written and copied into your repo {shadcn là một bộ các wrapper này được tuyển chọn, viết sẵn và copy vào repo của bạn}. That’s Part 9 {Đó là Phần 9}.
5. Exercises {Bài tập}
1. When should a Radix component be controlled rather than uncontrolled? {Khi nào một component Radix nên controlled thay vì uncontrolled?}
Solution {Lời giải}
When you need to read or set its state from outside — open it programmatically, sync to a URL/store, or trigger side effects on change {Khi bạn cần đọc/đặt trạng thái từ bên ngoài — mở bằng code, đồng bộ URL/store, hay chạy side effect khi đổi}.
2. Why does asChild require the child to forward its ref? {Vì sao asChild yêu cầu con forward ref?}
Solution {Lời giải}
Radix needs a ref to the real DOM node to manage focus, positioning, and outside-click; without forwarding, it can’t reach the element and behavior breaks {Radix cần ref tới node DOM thật để quản lý focus, định vị, bấm-ngoài; không forward thì nó không chạm được phần tử và hành vi hỏng}.
3. Style a Tabs.Trigger so the active tab has an indigo background and white text {Style một Tabs.Trigger để tab active có nền indigo và chữ trắng}.
Solution {Lời giải}
<Tabs.Trigger value="x"
className="data-[state=active]:bg-indigo-500 data-[state=active]:text-white">X</Tabs.Trigger>Stretch {Nâng cao}: in the lab, toggle the Switch and Popover and note both expose data-state — the same hook you’d target with data-[state=checked]: / data-[state=open]: {trong lab, bật Switch và Popover và để ý cả hai phơi data-state — cùng móc bạn nhắm bằng data-[state=checked]: / data-[state=open]:}.
Key takeaways {Điểm chính}
- Uncontrolled by default; go controlled (
open/onOpenChange…) only with a concrete need {Mặc định uncontrolled; dùng controlled chỉ khi có nhu cầu cụ thể}. asChild(viaSlot) merges Radix behavior onto your element — no wrapper DOM; the child must forwardref+ spread props {asChild(quaSlot) ghép hành vi Radix vào phần tử của bạn — không DOM bọc; con phải forwardref+ spread props}.- Every primitive shares the same
value/onValueChange+data-statemodel — learn one, know all {Mọi primitive chung mô hìnhvalue/onValueChange+data-state— học một, biết tất}. - Wrapping each primitive once with baked-in styles is the scalable pattern — and exactly what shadcn productizes {Bọc mỗi primitive một lần với style nung sẵn là mẫu scale được — và đúng thứ shadcn sản phẩm hóa}.
Next up {Tiếp theo}
Part 9 — shadcn/ui: the philosophy (“not a component library”), the CLI, components.json, and adding your first components — where Tailwind + Radix + cva/cn finally click into one workflow {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}.