Radix UI Primitives คืออะไร

Radix UI Primitives เป็น Low-level, Unstyled Component Library สำหรับ React ที่ให้ Behavior, Accessibility และ Interaction Patterns มาพร้อมใช้งาน โดยไม่บังคับ Style ทำให้สามารถ Customize UI ได้เต็มที่ด้วย CSS Framework ที่ต้องการ เช่น Tailwind CSS, CSS Modules หรือ Styled Components

จุดเด่นคือทุก Component ผ่านมาตรฐาน WAI-ARIA มีการจัดการ Keyboard Navigation, Focus Management, Screen Reader Support อย่างถูกต้อง เป็นพื้นฐานของ shadcn/ui ที่นิยมมากในปัจจุบัน การใช้ Radix UI ช่วยให้ผ่าน Accessibility Compliance ได้ง่ายขึ้นมาก

ติดตั้งและใช้งาน Radix UI

# ติดตั้ง Radix UI Primitives
npm install @radix-ui/react-dialog
npm install @radix-ui/react-dropdown-menu
npm install @radix-ui/react-tabs
npm install @radix-ui/react-tooltip
npm install @radix-ui/react-accordion
npm install @radix-ui/react-select
npm install @radix-ui/react-toast

# หรือติดตั้งทั้งหมดผ่าน shadcn/ui
npx shadcn-ui@latest init
npx shadcn-ui@latest add dialog dropdown-menu tabs tooltip

# --- ตัวอย่าง Accessible Dialog ---
// components/accessible-dialog.tsx
import * as Dialog from '@radix-ui/react-dialog';
import { X } from 'lucide-react';

export function AccessibleDialog({ trigger, title, description, children }) {
 return (
 <Dialog.Root>
 <Dialog.Trigger asChild>
 {trigger}
 </Dialog.Trigger>
 <Dialog.Portal>
 <Dialog.Overlay className="fixed inset-0 bg-black/50
 data-[state=open]:animate-fadeIn" />
 <Dialog.Content
 className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
 bg-white dark:bg-zinc-900 rounded-lg p-6 w-full max-w-md
 shadow-xl focus:outline-none
 data-[state=open]:animate-slideIn"
 aria-describedby={description ? 'dialog-desc' : undefined}
 >
 <Dialog.Title className="text-lg font-semibold">
 {title}
 </Dialog.Title>
 {description && (
 <Dialog.Description id="dialog-desc" className="text-sm
 text-zinc-500 mt-2">
 {description}
 </Dialog.Description>
 )}
 <div className="mt-4">{children}</div>
 <Dialog.Close asChild>
 <button
 className="absolute top-3 right-3 p-1 rounded-full
 hover:bg-zinc-100 dark:hover:bg-zinc-800"
 aria-label="Close dialog"
 >
 <X size={16} />
 </button>
 </Dialog.Close>
 </Dialog.Content>
 </Dialog.Portal>
 </Dialog.Root>
 );
}

// Radix UI จัดการให้อัตโนมัติ:
// - Focus Trap (Tab ไม่หลุดออกจาก Dialog)
// - ESC ปิด Dialog
// - Click Outside ปิด Dialog
// - aria-modal="true"
// - role="dialog"
// - Focus กลับไปที่ Trigger เมื่อปิด
// - Screen Reader ประกาศ Title และ Description

# --- ตัวอย่าง Accessible Dropdown Menu ---
// components/accessible-dropdown.tsx
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';

export function UserMenu({ user }) {
 return (
 <DropdownMenu.Root>
 <DropdownMenu.Trigger asChild>
 <button className="flex items-center gap-2 px-3 py-2 rounded-lg
 hover:bg-zinc-100 dark:hover:bg-zinc-800"
 aria-label={`User menu for `}>
 <img src={user.avatar} alt="" className="w-8 h-8 rounded-full" />
 <span>{user.name}</span>
 </button>
 </DropdownMenu.Trigger>

 <DropdownMenu.Portal>
 <DropdownMenu.Content className="bg-white dark:bg-zinc-900
 rounded-lg shadow-lg p-1 min-w-[200px]
 animate-in fade-in slide-in-from-top-2"
 sideOffset={5}>

 <DropdownMenu.Label className="px-3 py-1.5 text-xs
 text-zinc-500">
 Account
 </DropdownMenu.Label>

 <DropdownMenu.Item className="px-3 py-2 rounded cursor-pointer
 hover:bg-zinc-100 dark:hover:bg-zinc-800 outline-none
 data-[highlighted]:bg-zinc-100">
 Profile Settings
 </DropdownMenu.Item>

 <DropdownMenu.Item className="px-3 py-2 rounded cursor-pointer
 hover:bg-zinc-100 outline-none
 data-[highlighted]:bg-zinc-100">
 Billing
 </DropdownMenu.Item>

 <DropdownMenu.Separator className="h-px bg-zinc-200
 dark:bg-zinc-700 my-1" />

 <DropdownMenu.Item className="px-3 py-2 rounded cursor-pointer
 text-red-500 outline-none
 data-[highlighted]:bg-red-50">
 Sign Out
 </DropdownMenu.Item>

 <DropdownMenu.Arrow className="fill-white dark:fill-zinc-900" />
 </DropdownMenu.Content>
 </DropdownMenu.Portal>
 </DropdownMenu.Root>
 );
}

// Radix UI จัดการ Keyboard Navigation:
// - Arrow Up/Down เลื่อนระหว่าง Items
// - Enter/Space เลือก Item
// - ESC ปิด Menu
// - Type-ahead search

Compliance Automation Pipeline

# .github/workflows/accessibility-compliance.yml
# GitHub Actions — Accessibility Compliance Check

name: Accessibility Compliance
on:
 push:
 branches: [main, develop]
 pull_request:
 branches: [main]

jobs:
 axe-scan:
 name: Axe Accessibility Scan
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v4

 - name: Setup Node.js
 uses: actions/setup-node@v4
 with:
 node-version: 20
 cache: npm

 - name: Install Dependencies
 run: npm ci

 - name: Build
 run: npm run build

 - name: Start Server
 run: npm run preview &
 env:
 PORT: 3000

 - name: Wait for Server
 run: npx wait-on http://localhost:3000 --timeout 30000

 - name: Run Axe Accessibility Tests
 run: |
 npx @axe-core/cli http://localhost:3000 \
 --tags wcag2a, wcag2aa, wcag21aa \
 --exit \
 --save results/axe-report.json

 - name: Upload Report
 if: always()
 uses: actions/upload-artifact@v4
 with:
 name: accessibility-report
 path: results/

 playwright-a11y:
 name: Playwright Accessibility Tests
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v4

 - name: Setup Node.js
 uses: actions/setup-node@v4
 with:
 node-version: 20

 - name: Install Dependencies
 run: |
 npm ci
 npx playwright install --with-deps chromium

 - name: Run Accessibility Tests
 run: npx playwright test tests/accessibility/

 - name: Upload Report
 if: always()
 uses: actions/upload-artifact@v4
 with:
 name: playwright-a11y-report
 path: playwright-report/

 lighthouse:
 name: Lighthouse Accessibility Audit
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v4

 - name: Setup Node.js
 uses: actions/setup-node@v4
 with:
 node-version: 20

 - name: Build
 run: npm ci && npm run build

 - name: Lighthouse CI
 uses: treosh/lighthouse-ci-action@v11
 with:
 configPath: .lighthouserc.json
 uploadArtifacts: true

---
# .lighthouserc.json
{
 "ci": {
 "assert": {
 "assertions": {
 "categories:accessibility": ["error", {"minScore": 0.95}],
 "categories:performance": ["warn", {"minScore": 0.90}],
 "categories:best-practices": ["warn", {"minScore": 0.90}]
 }
 },
 "collect": {
 "startServerCommand": "npm run preview",
 "url": ["http://localhost:3000", "http://localhost:3000/about"]
 }
 }
}

Playwright Accessibility Tests

// tests/accessibility/dialog.spec.ts
// Playwright Tests สำหรับ Accessible Dialog
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Dialog Accessibility', () => {

 test('should pass axe accessibility scan', async ({ page }) => {
 await page.goto('/');
 await page.click('[data-testid="open-dialog"]');
 await page.waitForSelector('[role="dialog"]');

 const results = await new AxeBuilder({ page })
 .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
 .analyze();

 expect(results.violations).toEqual([]);
 });

 test('should trap focus inside dialog', async ({ page }) => {
 await page.goto('/');
 await page.click('[data-testid="open-dialog"]');

 // Tab through all focusable elements
 const dialog = page.locator('[role="dialog"]');
 await expect(dialog).toBeFocused();

 // Tab forward
 await page.keyboard.press('Tab');
 const firstFocusable = page.locator('[role="dialog"] :focus');
 await expect(firstFocusable).toBeVisible();

 // Tab should cycle within dialog
 for (let i = 0; i < 20; i++) {
 await page.keyboard.press('Tab');
 const focused = await page.evaluate(
 () => document.activeElement?.closest('[role="dialog"]') !== null
 );
 expect(focused).toBe(true);
 }
 });

 test('should close on ESC', async ({ page }) => {
 await page.goto('/');
 await page.click('[data-testid="open-dialog"]');
 await expect(page.locator('[role="dialog"]')).toBeVisible();

 await page.keyboard.press('Escape');
 await expect(page.locator('[role="dialog"]')).not.toBeVisible();
 });

 test('should return focus to trigger on close', async ({ page }) => {
 await page.goto('/');
 const trigger = page.locator('[data-testid="open-dialog"]');
 await trigger.click();
 await page.keyboard.press('Escape');
 await expect(trigger).toBeFocused();
 });

 test('should have correct ARIA attributes', async ({ page }) => {
 await page.goto('/');
 await page.click('[data-testid="open-dialog"]');

 const dialog = page.locator('[role="dialog"]');
 await expect(dialog).toHaveAttribute('aria-modal', 'true');
 await expect(dialog).toHaveAttribute('aria-labelledby', /.+/);
 });

 test('should announce to screen readers', async ({ page }) => {
 await page.goto('/');
 await page.click('[data-testid="open-dialog"]');

 // Check aria-labelledby points to title
 const labelledBy = await page.locator('[role="dialog"]')
 .getAttribute('aria-labelledby');
 const title = page.locator(`#`);
 await expect(title).toHaveText(/.+/);
 });
});

test.describe('Color Contrast', () => {
 test('all pages pass contrast requirements', async ({ page }) => {
 const pages = ['/', '/about', '/contact', '/dashboard'];

 for (const url of pages) {
 await page.goto(url);
 const results = await new AxeBuilder({ page })
 .withTags(['wcag2aa'])
 .withRules(['color-contrast'])
 .analyze();

 expect(results.violations, `Contrast issues on `).toEqual([]);
 }
 });
});

WCAG 2.1 Checklist

  • Color Contrast: Text ต้องมี Contrast Ratio 4.5:1 สำหรับ Normal Text, 3:1 สำหรับ Large Text ใช้ Radix Colors ที่ออกแบบมาให้ผ่าน
  • Keyboard Navigation: ทุก Interactive Element ต้องใช้ Keyboard ได้ Radix UI จัดการ Tab Order, Arrow Keys, Enter/Space ให้
  • Focus Indicators: ต้องมี Visual Focus Indicator ที่ชัดเจน ใช้ CSS outline หรือ ring ของ Tailwind
  • ARIA Labels: ทุก Icon Button ต้องมี aria-label ทุก Form Input ต้องมี Label ที่เชื่อมโยง
  • Screen Reader: Content ต้องอ่านได้ด้วย Screen Reader ใช้ Semantic HTML และ ARIA Roles ที่ถูกต้อง
  • Motion: Animation ต้อง Respect prefers-reduced-motion ให้ผู้ใช้ที่มีอาการเวียนศีรษะ
  • Responsive: Content ต้องใช้งานได้ทุกขนาดหน้าจอ ไม่มี Horizontal Scroll ที่ 320px

Radix UI Primitives คืออะไร

Radix UI เป็น Unstyled Accessible Component Library สำหรับ React ให้ Behavior และ Accessibility มาพร้อม เช่น Keyboard Navigation, Focus Management, ARIA Attributes Customize Style ได้เต็มที่ เป็นพื้นฐานของ shadcn/ui