วันที่เหนื่อยกับ CSS
ยังจำได้เลยวันที่ต้องเขียน modal component เพื่อใช้ในโปรเจค เขียน CSS จากศูนย์:
/* modal.css - ไฟล์ที่ทำให้ปวดหัว */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
transform: scale(0.9);
transition: transform 0.2s ease;
}
.modal-content.open {
transform: scale(1);
}
/* และอีกเป็นร้อยๆ บรรทัด... */
ปัญหาที่เจอ:
- ใช้เวลานาน เขียน CSS ครั้งเดียว 2-3 ชั่วโมง
- Responsive ต้องเขียนเพิ่ม
- Accessibility ลืมใส่
- Dark mode ต้องเขียนใหม่ทั้งหมด
- Consistency ระหว่าง components ไม่มี
แล้วก็มาเจอ shadcn/ui ที่เปลี่ยนชีวิต UI development ไปเลย! 🎯
การรู้จัก shadcn/ui ครั้งแรก
ตอนแรกคิดว่า “อีก UI library นึง ที่ต้องมา vendor lock-in” แต่พอดูดีดี เจอว่ามันไม่ใช่ library!
shadcn/ui คือ:
- Copy & paste components ไม่ใช่ npm package
- Built on Radix UI (accessibility เจ๋ง)
- Tailwind CSS styling
- TypeScript first
- Customizable ได้ 100%
# Setup ครั้งแรก
npx shadcn-ui@latest init
# ✨ Magic begins!
npx shadcn-ui@latest add button
npx shadcn-ui@latest add modal
npx shadcn-ui@latest add form
ประสบการณ์แรกที่ใช้
Before: Modal component จากนรก
// Modal.jsx - เขียนเอง 200+ lines
import React, { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import './Modal.css';
const Modal = ({ isOpen, onClose, children, title }) => {
const [isAnimating, setIsAnimating] = useState(false);
const modalRef = useRef();
useEffect(() => {
if (isOpen) {
setIsAnimating(true);
document.body.style.overflow = 'hidden';
// Focus management
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements?.[0];
firstElement?.focus();
// Escape key handler
const handleEscape = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
return () => {
document.body.style.overflow = 'unset';
document.removeEventListener('keydown', handleEscape);
};
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div
ref={modalRef}
className={`modal-content ${isAnimating ? 'open' : ''}`}
onClick={e => e.stopPropagation()}
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
className="modal-close"
onClick={onClose}
aria-label="Close modal"
>
×
</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>,
document.body
);
};
After: shadcn/ui Dialog
// ใช้ shadcn/ui - 10 lines จบ!
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
const MyModal = () => {
return (
<Dialog>
<DialogTrigger asChild>
<Button>Open Modal</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>My Amazing Modal</DialogTitle>
</DialogHeader>
<p>Modal content goes here!</p>
</DialogContent>
</Dialog>
)
}
ได้อะไรฟรีๆ:
- ✅ Accessibility (ARIA, focus management, keyboard navigation)
- ✅ Responsive design
- ✅ Dark mode support
- ✅ Smooth animations
- ✅ Portal rendering
- ✅ TypeScript support
เคสจริง: สร้าง Form ที่ใช้งานได้จริง
ความท้าทาย: Registration Form
// ก่อนใช้ shadcn/ui - Form validation นรก
const RegistrationForm = () => {
const [errors, setErrors] = useState({});
const [values, setValues] = useState({
email: '',
password: '',
confirmPassword: ''
});
const validate = () => {
const newErrors = {};
if (!values.email) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(values.email)) {
newErrors.email = 'Email is invalid';
}
if (!values.password) {
newErrors.password = 'Password is required';
} else if (values.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
if (values.password !== values.confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validate()) {
// Submit logic
}
};
return (
<form onSubmit={handleSubmit} className="registration-form">
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={values.email}
onChange={(e) => setValues({...values, email: e.target.value})}
className={errors.email ? 'error' : ''}
/>
{errors.email && <span className="error-message">{errors.email}</span>}
</div>
{/* อีกเป็นร้อยบรรทัด... */}
</form>
);
};
หลังใช้ shadcn/ui + React Hook Form + Zod
// shadcn/ui + modern form libraries = magic! ✨
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
const formSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})
const RegistrationForm = () => {
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
confirmPassword: "",
},
})
const onSubmit = (values) => {
console.log(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="Enter your email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Register</Button>
</form>
</Form>
)
}
ข้อดีที่ได้:
- Validation อัตโนมัติ
- Error messages สวยงาม
- TypeScript type safety
- Accessibility built-in
- Responsive design
ความผิดพลาดที่เจอตอนเริ่มใช้
1. ไม่เข้าใจ Compound Components Pattern
// ❌ คิดว่าใช้แบบ props drilling
<Dialog
isOpen={true}
title="My Modal"
onClose={() => {}}
>
Content here
</Dialog>
// ✅ จริงๆ ใช้ compound pattern
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>My Modal</DialogTitle>
</DialogHeader>
Content here
</DialogContent>
</Dialog>
2. Customization ที่ผิด
// ❌ พยายาม override ด้วย CSS
.my-custom-button {
background-color: red !important; /* 😱 */
}
// ✅ ใช้ className variants หรือ CSS variables
<Button className="bg-red-500 hover:bg-red-600">
Custom Button
</Button>
// หรือสร้าง variant ใหม่
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
danger: "bg-red-500 text-white hover:bg-red-600", // ✨ custom variant
},
}
}
)
3. Dark Mode ที่งง
// เริ่มต้นต้องเซต theme provider
import { ThemeProvider } from "@/components/theme-provider"
function App() {
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<Router>
{/* app content */}
</Router>
</ThemeProvider>
)
}
// Toggle theme
import { useTheme } from "@/components/theme-provider"
const ThemeToggle = () => {
const { setTheme, theme } = useTheme()
return (
<Button
variant="outline"
size="icon"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
)
}
Advanced Patterns ที่เรียนรู้
1. Custom Hook สำหรับ Complex Components
// useDataTable.js - จัดการ table state
import { useState, useMemo } from "react"
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
export const useDataTable = ({ data, columns }) => {
const [sorting, setSorting] = useState([])
const [columnFilters, setColumnFilters] = useState([])
const [rowSelection, setRowSelection] = useState({})
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
rowSelection,
},
})
return { table }
}
// DataTable.jsx
const DataTable = ({ data, columns }) => {
const { table } = useDataTable({ data, columns })
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : (
<Button
variant="ghost"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length}>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)
}
2. Generic Form Builder
// FormBuilder.jsx - สร้าง form จาก schema
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Checkbox } from "@/components/ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const FormBuilder = ({ schema, onSubmit, defaultValues = {} }) => {
const form = useForm({
resolver: zodResolver(schema.validation),
defaultValues,
})
const renderField = (field) => {
switch (field.type) {
case 'text':
case 'email':
case 'password':
return (
<FormField
key={field.name}
control={form.control}
name={field.name}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{field.label}</FormLabel>
<FormControl>
<Input
type={field.type}
placeholder={field.placeholder}
{...fieldProps}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)
case 'textarea':
return (
<FormField
key={field.name}
control={form.control}
name={field.name}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{field.label}</FormLabel>
<FormControl>
<Textarea
placeholder={field.placeholder}
{...fieldProps}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)
case 'select':
return (
<FormField
key={field.name}
control={form.control}
name={field.name}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{field.label}</FormLabel>
<Select onValueChange={fieldProps.onChange} defaultValue={fieldProps.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={field.placeholder} />
</SelectTrigger>
</FormControl>
<SelectContent>
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)
case 'checkbox':
return (
<FormField
key={field.name}
control={form.control}
name={field.name}
render={({ field: fieldProps }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={fieldProps.value}
onCheckedChange={fieldProps.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>{field.label}</FormLabel>
</div>
</FormItem>
)}
/>
)
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{schema.fields.map(renderField)}
<Button type="submit">Submit</Button>
</form>
</Form>
)
}
// Usage
const userSchema = {
validation: z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
role: z.string(),
newsletter: z.boolean().default(false),
}),
fields: [
{ name: 'name', type: 'text', label: 'Full Name', placeholder: 'Enter your name' },
{ name: 'email', type: 'email', label: 'Email', placeholder: 'Enter your email' },
{
name: 'role',
type: 'select',
label: 'Role',
placeholder: 'Select a role',
options: [
{ value: 'admin', label: 'Administrator' },
{ value: 'user', label: 'User' },
{ value: 'moderator', label: 'Moderator' }
]
},
{ name: 'newsletter', type: 'checkbox', label: 'Subscribe to newsletter' },
]
}
<FormBuilder
schema={userSchema}
onSubmit={(data) => console.log(data)}
/>
3. Responsive Navigation
// Navigation.jsx - mobile + desktop
import { useState } from "react"
import { Menu, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetTrigger,
} from "@/components/ui/sheet"
import { cn } from "@/lib/utils"
const Navigation = () => {
const [isOpen, setIsOpen] = useState(false)
const navItems = [
{ href: "/", label: "Home" },
{ href: "/about", label: "About" },
{ href: "/services", label: "Services" },
{ href: "/contact", label: "Contact" },
]
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center">
{/* Logo */}
<div className="mr-4 flex">
<a className="mr-6 flex items-center space-x-2" href="/">
<span className="font-bold">MyApp</span>
</a>
</div>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-6 text-sm font-medium">
{navItems.map((item) => (
<a
key={item.href}
href={item.href}
className="transition-colors hover:text-foreground/80 text-foreground/60"
>
{item.label}
</a>
))}
</nav>
<div className="flex flex-1 items-center justify-end space-x-2">
{/* Mobile Navigation */}
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left">
<nav className="grid gap-6 text-lg font-medium">
{navItems.map((item) => (
<a
key={item.href}
href={item.href}
onClick={() => setIsOpen(false)}
className="hover:text-foreground"
>
{item.label}
</a>
))}
</nav>
</SheetContent>
</Sheet>
{/* Theme Toggle */}
<ThemeToggle />
{/* User Menu */}
<Button variant="outline" size="sm">
Sign In
</Button>
</div>
</div>
</header>
)
}
Performance Optimizations
1. Lazy Loading Components
// LazyComponents.jsx
import { lazy, Suspense } from "react"
import { Skeleton } from "@/components/ui/skeleton"
// Lazy load heavy components
const DataTable = lazy(() => import("@/components/data-table"))
const Chart = lazy(() => import("@/components/chart"))
const Dashboard = () => {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
<Suspense
fallback={
<div className="space-y-2">
<Skeleton className="h-4 w-[250px]" />
<Skeleton className="h-4 w-[200px]" />
<Skeleton className="h-[200px] w-full" />
</div>
}
>
<DataTable />
</Suspense>
<Suspense fallback={<Skeleton className="h-[300px] w-full" />}>
<Chart />
</Suspense>
</div>
)
}
2. Virtual Scrolling สำหรับ Large Lists
// VirtualList.jsx
import { FixedSizeList as List } from "react-window"
import { Card, CardContent } from "@/components/ui/card"
const VirtualList = ({ items, height = 400 }) => {
const Row = ({ index, style }) => (
<div style={style}>
<Card className="mx-2 mb-2">
<CardContent className="p-4">
<h3 className="font-semibold">{items[index].title}</h3>
<p className="text-sm text-muted-foreground">
{items[index].description}
</p>
</CardContent>
</Card>
</div>
)
return (
<List
height={height}
itemCount={items.length}
itemSize={120}
className="border rounded-md"
>
{Row}
</List>
)
}
// Usage กับข้อมูลเยอะ
const itemsData = Array.from({ length: 10000 }, (_, i) => ({
id: i,
title: `Item ${i + 1}`,
description: `Description for item ${i + 1}`,
}))
<VirtualList items={itemsData} height={500} />
Testing shadcn/ui Components
// Button.test.jsx
import { render, screen, fireEvent } from "@testing-library/react"
import { Button } from "@/components/ui/button"
describe("Button", () => {
it("renders correctly", () => {
render(<Button>Click me</Button>)
expect(screen.getByRole("button")).toHaveTextContent("Click me")
})
it("handles click events", () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByRole("button"))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it("applies variant classes correctly", () => {
render(<Button variant="destructive">Delete</Button>)
const button = screen.getByRole("button")
expect(button).toHaveClass("bg-destructive")
})
it("supports disabled state", () => {
render(<Button disabled>Disabled</Button>)
expect(screen.getByRole("button")).toBeDisabled()
})
})
// Form.test.jsx
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { RegistrationForm } from "@/components/registration-form"
describe("RegistrationForm", () => {
it("shows validation errors", async () => {
const user = userEvent.setup()
render(<RegistrationForm />)
const submitButton = screen.getByRole("button", { name: /register/i })
await user.click(submitButton)
await waitFor(() => {
expect(screen.getByText("Invalid email address")).toBeInTheDocument()
})
})
it("submits valid form data", async () => {
const user = userEvent.setup()
const mockSubmit = jest.fn()
render(<RegistrationForm onSubmit={mockSubmit} />)
await user.type(screen.getByLabelText(/email/i), "test@example.com")
await user.type(screen.getByLabelText(/^password/i), "password123")
await user.type(screen.getByLabelText(/confirm password/i), "password123")
await user.click(screen.getByRole("button", { name: /register/i }))
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith({
email: "test@example.com",
password: "password123",
confirmPassword: "password123"
})
})
})
})
Storybook Integration
// Button.stories.jsx
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from '@/components/ui/button'
const meta: Meta<typeof Button> = {
title: 'UI/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: { type: 'select' },
options: ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'],
},
size: {
control: { type: 'select' },
options: ['default', 'sm', 'lg', 'icon'],
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
children: 'Button',
},
}
export const Destructive: Story = {
args: {
variant: 'destructive',
children: 'Delete',
},
}
export const WithIcon: Story = {
args: {
children: (
<>
<Mail className="mr-2 h-4 w-4" />
Send Email
</>
),
},
}
export const Loading: Story = {
args: {
disabled: true,
children: (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Please wait
</>
),
},
}
Production Tips
1. Bundle Size Optimization
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
external: ['react', 'react-dom'], // ถ้า SSR
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
ui: ['@radix-ui/react-dialog', '@radix-ui/react-form'],
}
}
}
}
})
// แยก components ที่ไม่ค่อยใช้
const HeavyComponent = lazy(() => import('./heavy-component'))
2. Accessibility Audit
# Install axe-core
npm install --save-dev @axe-core/react
# App.jsx
if (process.env.NODE_ENV !== 'production') {
import('@axe-core/react').then((axe) => {
axe.default(React, ReactDOM, 1000)
})
}
3. Theme Customization
/* globals.css */
@layer base {
:root {
/* Custom color palette */
--primary: 220 14% 11%;
--primary-foreground: 210 20% 98%;
/* Brand colors */
--brand-primary: 210 100% 50%;
--brand-secondary: 280 100% 70%;
/* Custom shadows */
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
.dark {
--primary: 210 20% 98%;
--primary-foreground: 220 14% 11%;
}
}
/* Component-specific customizations */
.btn-brand {
@apply bg-brand-primary hover:bg-brand-primary/90;
}
สรุป: shadcn/ui ที่เปลี่ยนชีวิต
ก่อนใช้ shadcn/ui:
- เขียน CSS ใช้เวลานาน
- Accessibility ลืมใส่
- Consistency ไม่มี
- Dark mode เขียนใหม่ทั้งหมด
หลังใช้ shadcn/ui:
- Copy & paste แล้วใช้ได้เลย
- Accessibility ครบ
- Design system สวยงาม
- Customizable 100%
ข้อดีที่ได้จริง:
- Development speed เร็วขึ้น 70%
- Code quality ดีขึ้น (TypeScript + accessibility)
- Maintenance ง่ายขึ้น
- Team consistency มี design language เดียวกัน
ข้อเสียที่ต้องยอมรับ:
- Learning curve ต้องเรียนรู้ Radix UI patterns
- Bundle size ใหญ่กว่า vanilla CSS
- Dependency ต้องพึ่ง Tailwind CSS
คำแนะนำ:
- เริ่มจาก basic components ก่อน (Button, Input)
- เรียนรู้ compound patterns ของ Radix UI
- ใช้ TypeScript ให้เต็มที่
- Setup Storybook สำหรับ documentation
- Customize ให้เข้ากับ brand
shadcn/ui มันเหมือน Swiss Army Knife ของ UI development
มีเครื่องมือครบครัน ใช้งานง่าย แต่ทรงพลังมาก 🔧✨
ตอนนี้ไม่อยากกลับไปเขียน CSS จากศูนย์อีกแล้ว เพราะ ชีวิตมันง่ายขึ้นเยอะ! 😄