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
Compliance Automation คืออะไร
การใช้เครื่องมืออัตโนมัติตรวจสอบว่าซอฟต์แวร์เป็นไปตามมาตรฐาน เช่น WCAG 2.1 สำหรับ Accessibility รวมเข้า CI/CD Pipeline ตรวจทุก Commit ใช้ Axe, Playwright, Lighthouse ตรวจสอบอัตโนมัติ Fail Build เมื่อไม่ผ่าน
WCAG 2.1 คืออะไร
มาตรฐานสากลสำหรับ Web Accessibility 4 หลักคือ Perceivable, Operable, Understandable, Robust 3 ระดับ A, AA, AAA ส่วนใหญ่ตั้งเป้า AA ครอบคลุม Contrast Ratio, Keyboard Navigation, Screen Reader Support หลายประเทศบังคับใช้ตามกฎหมาย
ทำไมต้องใช้ Radix UI แทนการเขียนเอง
Accessibility ซับซ้อนมาก Dialog, Dropdown, Tooltip ต้องจัดการ Focus Trap, Keyboard Events, Screen Reader Radix UI จัดการทั้งหมดให้ถูกต้องตาม WAI-ARIA ลด Bug ผ่าน Compliance ง่ายขึ้น ประหยัดเวลาพัฒนาหลายสัปดาห์
สรุป
Radix UI Primitives ช่วยให้สร้าง Accessible UI ได้ง่ายและถูกต้องตาม WCAG 2.1 เมื่อรวมกับ Compliance Automation ใน CI/CD Pipeline ใช้ Axe, Playwright และ Lighthouse ตรวจสอบทุก Commit อัตโนมัติ ทำให้มั่นใจว่า UI ผ่านมาตรฐาน Accessibility ตลอดเวลา ไม่ต้องตรวจสอบ Manual ทุกครั้ง
