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.

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ụ}:

PartResponsibility
Rootholds open/closed state, wires everything together
Triggerthe button; toggles state, gets aria-haspopup/aria-expanded
Portalrenders Content at the end of <body> so it escapes overflow/z-index traps
Overlaythe backdrop; click-to-dismiss; data-state
Contentthe panel; focus trap, role="dialog", aria-modal, Escape handling
Title/Descriptionwire aria-labelledby/aria-describedby automatically
Closeany 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-disableddata-[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 ↔ Tailwind data-[…]: variants {Hợp đồng style là thuộc tính data-* ↔ variant data-[…]: 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}.