Radix UI Primitives คืออะไร
Radix UI เป็น open source component library สำหรับ React ที่ให้ unstyled, accessible primitives สำหรับสร้าง UI components คุณภาพสูง ต่างจาก component libraries อื่นที่มาพร้อม styles Radix UI ให้เฉพาะ behavior, accessibility และ keyboard interactions ให้ developers style เองตามต้องการ
ข้อดีของ Radix UI ได้แก่ WAI-ARIA compliant ทุก component accessible ตั้งแต่แรก, Unstyled ไม่มี opinionated styles ใช้กับ Tailwind CSS หรือ CSS-in-JS ได้, Composable ทุก component ประกอบจาก primitives เล็กๆ, Controlled/Uncontrolled รองรับทั้งสองรูปแบบ, TypeScript support types ครบถ้วน และ SSR compatible ใช้กับ Next.js ได้
Feature Flag Management สำหรับ UI components ช่วยให้ toggle UI features โดยไม่ต้อง redeploy, A/B test different component variants, gradual rollout ของ new UI designs, emergency disable ของ problematic features และ personalize UI ตาม user segments
การรวม Radix UI กับ Feature Flags ทำให้สามารถ switch ระหว่าง component versions (เช่น Dialog V1 กับ V2), enable/disable experimental features (เช่น new navigation), customize component behavior ตาม user role และ test new accessibility improvements กับ subset ของ users
ติดตั้งและใช้งาน Radix UI
เริ่มต้นใช้งาน Radix UI Primitives
# === ติดตั้ง Radix UI ===
# สร้าง React project ด้วย Vite
npm create vite@latest my-app -- --template react-ts
cd my-app
# ติดตั้ง 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-switch
npm install @radix-ui/react-select
npm install @radix-ui/react-accordion
npm install @radix-ui/react-toast
npm install @radix-ui/react-popover
# ติดตั้ง styling dependencies
npm install tailwindcss @tailwindcss/vite
npm install clsx tailwind-merge
npm install class-variance-authority
# ติดตั้ง feature flag dependencies
npm install launchdarkly-react-client-sdk
# หรือ
npm install @unleash/proxy-client-react
# === Project Structure ===
# src/
# ├── components/
# │ ├── ui/ # Radix UI wrapped components
# │ │ ├── Dialog.tsx
# │ │ ├── Button.tsx
# │ │ ├── Select.tsx
# │ │ └── Toast.tsx
# │ ├── features/ # Feature-flagged components
# │ │ ├── NewNavigation.tsx
# │ │ └── ExperimentalSearch.tsx
# │ └── shared/
# ├── hooks/
# │ ├── useFeatureFlag.ts
# │ └── useFeatureFlags.ts
# ├── providers/
# │ └── FeatureFlagProvider.tsx
# └── lib/
# ├── flags.ts
# └── utils.ts
# === tailwind.config.ts ===
# import type { Config } from 'tailwindcss'
# export default {
# content: ['./src/**/*.{ts, tsx}'],
# theme: {
# extend: {
# keyframes: {
# 'dialog-overlay-show': {
# from: { opacity: '0' },
# to: { opacity: '1' },
# },
# 'dialog-content-show': {
# from: { opacity: '0', transform: 'translate(-50%,-48%) scale(0.96)' },
# to: { opacity: '1', transform: 'translate(-50%,-50%) scale(1)' },
# },
# },
# animation: {
# 'dialog-overlay': 'dialog-overlay-show 150ms ease',
# 'dialog-content': 'dialog-content-show 150ms ease',
# },
# },
# },
# } satisfies Config
echo "Radix UI project setup complete"
สร้าง Feature Flag System สำหรับ React
Feature Flag Provider และ hooks
// === FeatureFlagProvider.tsx ===
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface FeatureFlags {
[key: string]: boolean | string | number;
}
interface FeatureFlagContextType {
flags: FeatureFlags;
isEnabled: (flagName: string) => boolean;
getValue: (flagName: string) => any;
loading: boolean;
}
const FeatureFlagContext = createContext<FeatureFlagContextType>({
flags: {},
isEnabled: () => false,
getValue: () => undefined,
loading: true,
});
// Default flags (fallback when service unavailable)
const DEFAULT_FLAGS: FeatureFlags = {
'new-dialog-design': false,
'experimental-search': false,
'dark-mode-v2': false,
'new-navigation': false,
'toast-animations': true,
'max-upload-size-mb': 10,
'onboarding-variant': 'control',
};
interface Props {
children: ReactNode;
apiUrl?: string;
userId?: string;
}
export function FeatureFlagProvider({ children, apiUrl, userId }: Props) {
const [flags, setFlags] = useState<FeatureFlags>(DEFAULT_FLAGS);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchFlags() {
try {
if (apiUrl) {
const resp = await fetch(`/flags?user=`);
const data = await resp.json();
setFlags({ ...DEFAULT_FLAGS, ...data });
}
} catch (error) {
console.warn('Feature flags fetch failed, using defaults');
} finally {
setLoading(false);
}
}
fetchFlags();
}, [apiUrl, userId]);
const isEnabled = (flagName: string): boolean => {
const value = flags[flagName];
return value === true || value === 'true';
};
const getValue = (flagName: string) => flags[flagName];
return (
<FeatureFlagContext.Provider value={{ flags, isEnabled, getValue, loading }}>
{children}
</FeatureFlagContext.Provider>
);
}
// === useFeatureFlag.ts ===
export function useFeatureFlag(flagName: string): boolean {
const { isEnabled } = useContext(FeatureFlagContext);
return isEnabled(flagName);
}
export function useFeatureFlagValue(flagName: string) {
const { getValue } = useContext(FeatureFlagContext);
return getValue(flagName);
}
export function useFeatureFlags() {
return useContext(FeatureFlagContext);
}
// === FeatureGate Component ===
interface FeatureGateProps {
flag: string;
children: ReactNode;
fallback?: ReactNode;
}
export function FeatureGate({ flag, children, fallback = null }: FeatureGateProps) {
const enabled = useFeatureFlag(flag);
return enabled ? <>{children}</> : <>{fallback}</>;
}
// === Usage in App.tsx ===
// <FeatureFlagProvider apiUrl="/api" userId={user.id}>
// <App />
// </FeatureFlagProvider>
//
// In components:
// const showNewNav = useFeatureFlag('new-navigation');
// <FeatureGate flag="experimental-search">
// <ExperimentalSearch />
// </FeatureGate>
รวม Radix UI กับ Feature Flags
ใช้ Feature Flags ควบคุม Radix UI components
// === Feature-Flagged Dialog Component ===
import * as Dialog from '@radix-ui/react-dialog';
import { useFeatureFlag } from '../hooks/useFeatureFlag';
// Dialog V1 (current)
function DialogV1({ trigger, title, children, open, onOpenChange }) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40" />
<Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6 w-[420px] shadow-xl">
<Dialog.Title className="text-lg font-semibold">{title}</Dialog.Title>
<div className="mt-4">{children}</div>
<Dialog.Close asChild>
<button className="absolute top-3 right-3 text-gray-400 hover:text-gray-600">
✕
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
// Dialog V2 (new design - behind feature flag)
function DialogV2({ trigger, title, children, open, onOpenChange }) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/60 backdrop-blur-sm animate-dialog-overlay" />
<Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-900 rounded-2xl p-8 w-[480px] shadow-2xl animate-dialog-content border border-gray-200 dark:border-gray-700">
<Dialog.Title className="text-xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
{title}
</Dialog.Title>
<Dialog.Description className="text-sm text-gray-500 mt-1">
Make changes below
</Dialog.Description>
<div className="mt-6">{children}</div>
<Dialog.Close asChild>
<button className="absolute top-4 right-4 w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center hover:bg-gray-200 transition-colors">
✕
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
// Smart Dialog that switches based on feature flag
export function SmartDialog(props) {
const useNewDesign = useFeatureFlag('new-dialog-design');
return useNewDesign ? <DialogV2 {...props} /> : <DialogV1 {...props} />;
}
// === Feature-Flagged Navigation ===
import * as NavigationMenu from '@radix-ui/react-navigation-menu';
import { FeatureGate } from '../providers/FeatureFlagProvider';
function MainNavigation() {
return (
<nav>
<NavigationMenu.Root className="relative z-10">
<NavigationMenu.List className="flex gap-2 p-2">
<NavigationMenu.Item>
<NavigationMenu.Link href="/" className="px-3 py-2 rounded hover:bg-gray-100">
Home
</NavigationMenu.Link>
</NavigationMenu.Item>
<NavigationMenu.Item>
<NavigationMenu.Link href="/products" className="px-3 py-2 rounded hover:bg-gray-100">
Products
</NavigationMenu.Link>
</NavigationMenu.Item>
{/* Feature-flagged nav item */}
<FeatureGate flag="new-navigation">
<NavigationMenu.Item>
<NavigationMenu.Trigger className="px-3 py-2 rounded hover:bg-gray-100">
AI Tools ✨
</NavigationMenu.Trigger>
<NavigationMenu.Content className="absolute top-full left-0 w-[400px] bg-white rounded-lg shadow-lg p-4 mt-2">
<div className="grid grid-cols-2 gap-3">
<a href="/ai/chat" className="p-3 rounded hover:bg-gray-50">
<div className="font-medium">AI Chat</div>
<div className="text-sm text-gray-500">Chat assistant</div>
</a>
<a href="/ai/image" className="p-3 rounded hover:bg-gray-50">
<div className="font-medium">Image Gen</div>
<div className="text-sm text-gray-500">Generate images</div>
</a>
</div>
</NavigationMenu.Content>
</NavigationMenu.Item>
</FeatureGate>
</NavigationMenu.List>
</NavigationMenu.Root>
</nav>
);
}
Testing Components กับ Feature Flags
ทดสอบ components ที่ใช้ feature flags
// === Testing Feature-Flagged Components ===
// __tests__/SmartDialog.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { FeatureFlagProvider } from '../providers/FeatureFlagProvider';
import { SmartDialog } from '../components/ui/SmartDialog';
// Helper to render with feature flags
function renderWithFlags(ui: React.ReactElement, flags: Record<string, any> = {}) {
// Mock the flag context
return render(
<FeatureFlagProvider>
{ui}
</FeatureFlagProvider>
);
}
describe('SmartDialog', () => {
const defaultProps = {
trigger: <button>Open</button>,
title: 'Test Dialog',
children: <p>Dialog content</p>,
};
it('renders V1 dialog when flag is disabled', () => {
renderWithFlags(<SmartDialog {...defaultProps} />);
fireEvent.click(screen.getByText('Open'));
expect(screen.getByText('Test Dialog')).toBeInTheDocument();
expect(screen.getByText('Dialog content')).toBeInTheDocument();
});
it('renders dialog with correct accessibility', () => {
renderWithFlags(<SmartDialog {...defaultProps} />);
fireEvent.click(screen.getByText('Open'));
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveAttribute('aria-modal', 'true');
});
it('closes on escape key', () => {
renderWithFlags(<SmartDialog {...defaultProps} />);
fireEvent.click(screen.getByText('Open'));
expect(screen.getByText('Dialog content')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByText('Dialog content')).not.toBeInTheDocument();
});
});
// === Feature Flag Testing Utilities ===
// test-utils/flags.ts
export function createMockFlags(overrides: Record<string, any> = {}) {
return {
'new-dialog-design': false,
'experimental-search': false,
'new-navigation': false,
'dark-mode-v2': false,
'toast-animations': true,
...overrides,
};
}
// Custom render that accepts flags
export function renderWithFeatureFlags(
ui: React.ReactElement,
flags: Record<string, any> = {}
) {
const allFlags = createMockFlags(flags);
return render(
<TestFeatureFlagProvider flags={allFlags}>
{ui}
</TestFeatureFlagProvider>
);
}
// === E2E Test with Playwright ===
// e2e/feature-flags.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Feature Flagged Components', () => {
test('shows new navigation when flag enabled', async ({ page }) => {
// Set feature flag via API/cookie before navigating
await page.addInitScript(() => {
window.__FEATURE_FLAGS__ = { 'new-navigation': true };
});
await page.goto('/');
// New nav item should be visible
await expect(page.getByText('AI Tools')).toBeVisible();
});
test('hides new navigation when flag disabled', async ({ page }) => {
await page.addInitScript(() => {
window.__FEATURE_FLAGS__ = { 'new-navigation': false };
});
await page.goto('/');
await expect(page.getByText('AI Tools')).not.toBeVisible();
});
test('dialog V2 has new design elements', async ({ page }) => {
await page.addInitScript(() => {
window.__FEATURE_FLAGS__ = { 'new-dialog-design': true };
});
await page.goto('/demo');
await page.click('button:has-text("Open Dialog")');
// V2 has gradient title
const title = page.locator('[role="dialog"] h2');
await expect(title).toBeVisible();
});
});
Production Deployment และ A/B Testing
Deploy และ A/B test components
# === Production Feature Flag Setup ===
# 1. Feature Flag API Server (Express)
# ===================================
# server.ts
# import express from 'express';
# import { createHash } from 'crypto';
#
# const app = express();
#
# interface FlagConfig {
# enabled: boolean;
# rolloutPct: number;
# variants?: Record<string, number>; // variant -> weight
# }
#
# const FLAGS: Record<string, FlagConfig> = {
# 'new-dialog-design': { enabled: true, rolloutPct: 25 },
# 'new-navigation': { enabled: true, rolloutPct: 50 },
# 'experimental-search': { enabled: false, rolloutPct: 0 },
# 'onboarding-variant': {
# enabled: true,
# rolloutPct: 100,
# variants: { control: 50, variant_a: 25, variant_b: 25 },
# },
# };
#
# function getUserBucket(userId: string, flagName: string): number {
# const hash = createHash('md5')
# .update(`:`)
# .digest('hex');
# return parseInt(hash.substring(0, 8), 16) % 100;
# }
#
# app.get('/api/flags', (req, res) => {
# const userId = req.query.user as string || 'anonymous';
# const result: Record<string, any> = {};
#
# for (const [name, config] of Object.entries(FLAGS)) {
# if (!config.enabled) {
# result[name] = false;
# continue;
# }
#
# const bucket = getUserBucket(userId, name);
#
# if (config.variants) {
# let cumulative = 0;
# for (const [variant, weight] of Object.entries(config.variants)) {
# cumulative += weight;
# if (bucket < cumulative) {
# result[name] = variant;
# break;
# }
# }
# } else {
# result[name] = bucket < config.rolloutPct;
# }
# }
#
# res.json(result);
# });
# 2. Analytics Integration
# ===================================
# Track which variant users see
# function trackFeatureExposure(flagName: string, variant: string, userId: string) {
# fetch('/api/analytics/track', {
# method: 'POST',
# headers: { 'Content-Type': 'application/json' },
# body: JSON.stringify({
# event: 'feature_exposure',
# properties: {
# flag_name: flagName,
# variant: variant,
# user_id: userId,
# timestamp: new Date().toISOString(),
# },
# }),
# });
# }
# 3. CI/CD Pipeline
# ===================================
# .github/workflows/deploy.yml
# name: Deploy with Feature Flags
# on:
# push:
# branches: [main]
#
# jobs:
# deploy:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: 20
#
# - run: npm ci
# - run: npm run test
# - run: npm run build
#
# # Verify feature flags are properly configured
# - name: Validate Feature Flags
# run: |
# node -e "
# const flags = require('./src/lib/flags');
# const required = ['new-dialog-design', 'new-navigation'];
# for (const f of required) {
# if (!(f in flags.DEFAULT_FLAGS)) {
# throw new Error('Missing flag: ' + f);
# }
# }
# console.log('Feature flags validated');
# "
#
# - name: Deploy
# run: npm run deploy
echo "Production setup complete"
FAQ คำถามที่พบบ่อย
Q: Radix UI กับ shadcn/ui ต่างกันอย่างไร?
A: Radix UI เป็น primitive library ที่ให้ behavior และ accessibility แต่ไม่มี styles shadcn/ui สร้างบน Radix UI โดยเพิ่ม Tailwind CSS styles ให้พร้อมใช้งาน ใช้ copy-paste approach (ไม่ใช่ npm package) ทำให้ customize ได้ง่าย สำหรับ projects ใหม่แนะนำ shadcn/ui เพราะประหยัดเวลา styling สำหรับ projects ที่ต้องการ design system เฉพาะ ใช้ Radix UI โดยตรงแล้ว style เอง
Q: Feature Flag ทำให้ bundle size ใหญ่ขึ้นไหม?
A: ถ้า import ทั้ง V1 และ V2 components จะเพิ่ม bundle size ทางแก้ใช้ React.lazy() กับ dynamic imports เพื่อ code-split feature-flagged components โหลดเฉพาะ version ที่ user ได้รับ สำหรับ flags ที่ควบคุม small UI changes (เช่น สี, text) ไม่กระทบ bundle size สำหรับ flags ที่ควบคุม entire pages/features ใช้ route-based code splitting
Q: จะทำ gradual rollout ของ new UI อย่างไร?
A: เริ่มจาก internal users (employees) 100%, ขยายเป็น beta users 100%, จากนั้น public users 5% -> 25% -> 50% -> 100% ที่แต่ละ stage monitor metrics สำคัญ (conversion rate, error rate, page load time, user feedback) ถ้า metrics ดีขึ้นหรือคงที่ เพิ่ม rollout percentage ถ้า metrics แย่ลง rollback ทันทีโดย set percentage เป็น 0
Q: Accessibility testing สำหรับ feature-flagged components ทำอย่างไร?
A: ต้อง test accessibility ทั้ง enabled และ disabled states ของทุก flag ใช้ axe-core หรือ Playwright accessibility testing ทดสอบ keyboard navigation, screen reader, focus management สำหรับทุก variant Radix UI primitives มี accessibility built-in แต่ custom styles อาจทำให้ contrast ratio ไม่ผ่าน ใช้ automated testing ใน CI pipeline ทดสอบทุก flag combination ที่เป็นไปได้
