jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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ầuNên dùng
Button, badge, card tĩnhHTML + Tailwind + cva nếu có variant
Dialog, popover, dropdown, tooltip, selectRadix primitive
Tabs/switch/checkbox/radio cần keyboard + ARIA chuẩnRadix primitive
Layout panel/sidebar tĩnhCSS Grid/Flex + component của bạn
Animation vào/ra cho layer nổiRadix 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ểuVí dụHành vi & a11yStyleBạn override style?
StyledMUI, Bootstrap, AntGói cứng sẵnPhải, thường đau
HeadlessRadix, Headless UI, React AriaKhôngKhông cần — bạn style
CSS-onlyPico, BulmaKhông (chỉ CSS)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ánNếu thiếu thì saoRadix làm gì
1Focus managementMở 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
2Focus trapTab trong modal nhảy ra link phía sau backdropGiữ Tab/Shift+Tab vòng trong panel
3Keyboard navMenu chỉ bấm được bằng chuộtPhím ↑↓, Home/End, typeahead, Esc
4ARIA wiringScreen reader đọc “button” trống rỗng, không biết là menu/dialogTự gắn role, aria-expanded, aria-haspopup, aria-labelledby
5Portal & layeringPanel bị cha overflow:hidden cắt mất, hoặc z-index thuaRender ở cuối <body>, thoát mọi bẫy stacking
6Dismiss layerBấ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 traptrả 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àiImportKhi nào dùng
Gói riêng @radix-ui/react-dialogimport * as Dialog from '@radix-ui/react-dialog'Production — chỉ kéo cái đang dùng
Gói gộp radix-uiimport { 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-ui và đặ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ầnTrách nhiệma11y tự động
RootGiữ state open/closed, cấp Context cho mọi con
TriggerNút kích hoạt; toggle statearia-haspopup, aria-expanded, aria-controls
PortalRender Content ở cuối <body> → thoát overflow/z-index
OverlayNền mờ; bấm để đóng; phơi data-stateChặn tương tác nền
ContentKhung nổi; bẫy focus, Escape, click-outsiderole="dialog", aria-modal="true"
TitleTiêu đềnối aria-labelledby
DescriptionMô tảnối aria-describedby
CloseBất kỳ phần tử nào đóng dialog

Hai điều cốt lõi cần ghi nhớ:

  1. Bạn cấp mọi className. Radix không bao giờ tự áp style. Không có className thì component vẫn chạy đúng nhưng trần trụi.
  2. 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ùng VisuallyHidden (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>

Controlledbạ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:

PrimitiveUncontrolled (mặc định)Controlled (cặp prop)
Dialog, Popover, Tooltip…defaultOpenopen + onOpenChange
Tabs, Select, RadioGroup…defaultValuevalue + onValueChange
Switch, Checkbox…defaultCheckedchecked + onCheckedChange
AccordiondefaultValuevalue + 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 itemdata-[state=checked]:…
data-state="active" / "inactive"Tabs triggerdata-[state=active]:…
data-highlightedMenu item, Select item (đang active bằng phím hoặc chuột)data-[highlighted]:…
data-disabledMọi item/trigger bị vô hiệudata-[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, Accordiondata-[orientation=vertical]:…
data-placeholderSelect value khi chưa chọndata-[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:

PrimitivePhần chínhMục đích một dòng
DialogRoot/Trigger/Portal/Overlay/Content/Title/CloseHộp thoại modal bẫy focus, chặn nền
AlertDialoggiống Dialog + Action/CancelDialog xác nhận hành động phá hủy (không đóng được bằng click-outside)
PopoverRoot/Trigger/Portal/ContentKhung nổi tùy ý gắn vào một trigger (không modal)
DropdownMenuRoot/Trigger/Portal/Content/Item/SeparatorMenu lệnh điều hướng đầy đủ bàn phím
ContextMenugiống Dropdown nhưng mở bằng chuột phảiMenu chuột phải
TooltipProvider/Root/Trigger/Portal/ContentGợi ý ngắn hiện khi hover/focus
HoverCardRoot/Trigger/Portal/ContentThẻ xem trước hiện khi hover (rich hơn tooltip)
AccordionRoot/Item/Header/Trigger/ContentDanh sách mục co/giãn được
CollapsibleRoot/Trigger/ContentMột vùng đóng/mở đơn lẻ
TabsRoot/List/Trigger/ContentChuyển giữa các panel cùng cấp
SwitchRoot/ThumbCông tắc bật/tắt (boolean)
CheckboxRoot/IndicatorÔ chọn, hỗ trợ cả trạng thái indeterminate
RadioGroupRoot/Item/IndicatorChọn một trong nhiều
SelectRoot/Trigger/Value/Portal/Content/ItemDropdown chọn giá trị, đầy đủ bàn phím
SliderRoot/Track/Range/ThumbThanh kéo chọn số (một hoặc nhiều thumb)
Toggle / ToggleGroupRoot (+ Item)Nút nhấn-giữ-trạng-thái / nhóm nút
ToastProvider/Root/Title/Description/ViewportThông báo nổi tạm thời, xếp chồng
ProgressRoot/IndicatorThanh tiến trình có ARIA
ScrollAreaRoot/Viewport/Scrollbar/ThumbVùng cuộn với thanh cuộn tùy biến
AvatarRoot/Image/FallbackẢnh đại diện có fallback khi lỗi
NavigationMenuRoot/List/Item/Trigger/ContentThanh điều hướng có submenu

Mọi primitive trong bảng đều theo đúng khuôn Root/.../Content ở Phần 4 và phơi data-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-0 mà quên cặp data-[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ào data-state=closed, không phải open. Đâ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ằng VisuallyHidden), Tooltip/Item icon-only cần aria-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 đừng outline-none mà 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ằng motion-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ứngNguyên nhânCách xử lý
Panel bị cha cắt mất / nằm dưới phần khácQuên Portal, nên Content kẹt trong overflow:hidden / z-index của tổ tiênBọ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-outThêm cặp closed (Phần 9)
Console cảnh báo “missing Title/Description”Dialog thiếu Dialog.TitleThê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ímStyle bằng hover: thay vì data-[highlighted]:Dùng data-[highlighted]: — nó phủ cả chuột phím
setOpen không có tác dụngĐang ở chế độ uncontrolled mà lại cố điều khiển từ ngoàiChuyển sang controlled: open + onOpenChange (Phần 5)
Animation giật khi prefers-reduced-motionKhông tôn trọng cài đặt hệ điều hànhThêm motion-reduce:animate-none
Trigger không nhận style của bạn / lồng nút trong nútMuốn dùng phần tử của riêng mình làm triggerDù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ắn className.
  • 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…) ↔ variant data-[…]: 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-in 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.