技術 6 分鐘閱讀

Tailwind CSS 元件設計模式:打造可重用的 UI 元件

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 #元件設計
Thousand Lab

Thousand Lab