技術 6 分鐘閱讀
Tailwind CSS 元件設計模式:打造可重用的 UI 元件
前言
Tailwind CSS 的 utility-first 寫法讓很多人一開始覺得「class 也太長了吧」。但當你學會用元件化的方式組織程式碼,就會發現 Tailwind 其實非常適合建立可重用的 UI 元件。
基本元件設計原則
使用 cva 管理樣式變體
class-variance-authority(cva)是管理 Tailwind 元件變體的好工具:
npm install class-variance-authority
按鈕元件
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
danger: 'bg-red-600 text-white hover:bg-red-700',
ghost: 'hover:bg-gray-100 text-gray-700',
link: 'text-blue-600 underline-offset-4 hover:underline',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean;
}
export function Button({
className,
variant,
size,
isLoading,
children,
...props
}: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
disabled={isLoading}
{...props}
>
{isLoading && (
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{children}
</button>
);
}
使用方式:
<Button variant="primary" size="lg">送出</Button>
<Button variant="danger" isLoading>刪除中...</Button>
<Button variant="ghost" size="sm">取消</Button>
cn 工具函式
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
cn 函式可以智慧合併 Tailwind class,避免衝突:
cn('px-4 py-2', 'px-6') // => 'py-2 px-6'(px-6 覆蓋 px-4)
卡片元件
interface CardProps {
children: React.ReactNode;
className?: string;
}
export function Card({ children, className }: CardProps) {
return (
<div className={cn(
'rounded-xl border border-gray-200 bg-white shadow-sm',
className
)}>
{children}
</div>
);
}
export function CardHeader({ children, className }: CardProps) {
return (
<div className={cn('border-b border-gray-200 px-6 py-4', className)}>
{children}
</div>
);
}
export function CardBody({ children, className }: CardProps) {
return (
<div className={cn('px-6 py-4', className)}>
{children}
</div>
);
}
export function CardFooter({ children, className }: CardProps) {
return (
<div className={cn(
'border-t border-gray-200 px-6 py-4 flex items-center justify-end gap-2',
className
)}>
{children}
</div>
);
}
使用方式:
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">訂單詳情</h3>
</CardHeader>
<CardBody>
<p>訂單內容...</p>
</CardBody>
<CardFooter>
<Button variant="secondary">取消</Button>
<Button variant="primary">確認</Button>
</CardFooter>
</Card>
表單輸入元件
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}
export function Input({
label,
error,
helperText,
className,
id,
...props
}: InputProps) {
const inputId = id || label?.toLowerCase().replace(/\s/g, '-');
return (
<div className="space-y-1">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700"
>
{label}
</label>
)}
<input
id={inputId}
className={cn(
'block w-full rounded-lg border px-3 py-2 text-sm',
'focus:outline-none focus:ring-2 focus:ring-offset-1',
error
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500',
className
)}
{...props}
/>
{error && <p className="text-sm text-red-600">{error}</p>}
{helperText && !error && (
<p className="text-sm text-gray-500">{helperText}</p>
)}
</div>
);
}
Badge 元件
const badgeVariants = cva(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
{
variants: {
variant: {
default: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
error: 'bg-red-100 text-red-800',
info: 'bg-blue-100 text-blue-800',
},
},
defaultVariants: {
variant: 'default',
},
}
);
interface BadgeProps extends VariantProps<typeof badgeVariants> {
children: React.ReactNode;
}
export function Badge({ variant, children }: BadgeProps) {
return <span className={badgeVariants({ variant })}>{children}</span>;
}
響應式設計模式
網格佈局
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
容器元件
export function Container({ children, className }: CardProps) {
return (
<div className={cn('mx-auto max-w-7xl px-4 sm:px-6 lg:px-8', className)}>
{children}
</div>
);
}
暗色模式支援
Tailwind 內建的 dark: 前綴讓暗色模式非常容易實作:
<div className="bg-white dark:bg-gray-900">
<h1 className="text-gray-900 dark:text-white">標題</h1>
<p className="text-gray-600 dark:text-gray-400">內文</p>
</div>
在元件中也要考慮暗色模式:
const buttonVariants = cva(
'...',
{
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100',
},
},
}
);
結語
Tailwind CSS 搭配元件化設計,能讓你快速建立一致且美觀的 UI。關鍵在於建立一套自己的元件庫,之後每個專案都能重複使用。我自己在接案時有一組常用的元件範本,每次新專案只要稍微修改主題色就能上線,大幅節省開發時間。
#Tailwind CSS #CSS #React #元件設計