名称: tailwind-components 用户可调用: false 描述: 用于使用Tailwind CSS构建可重用组件模式。涵盖组件提取、@apply指令和可组合设计模式。 允许工具:
- 读取
- 写入
- 编辑
- Bash
- Grep
- Glob
Tailwind CSS - 组件模式
虽然Tailwind是实用程序优先的,但你通常希望将常见模式提取为可重用组件。本技能涵盖了使用Tailwind构建可维护组件架构的策略。
关键概念
组件提取策略
有几种方法可以使用Tailwind创建可重用组件:
- 模板/组件抽象(推荐)
- CSS
@apply指令(谨慎使用) - JavaScript/TypeScript组件类
- Tailwind插件
模板组件抽象
最可维护的方法是在模板级别提取组件:
// Button.tsx
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'outline'
size?: 'sm' | 'md' | 'lg'
children: React.ReactNode
onClick?: () => void
}
export function Button({
variant = 'primary',
size = 'md',
children,
onClick
}: ButtonProps) {
const baseClasses = 'font-semibold rounded transition-colors focus:ring-2 focus:ring-offset-2'
const variantClasses = {
primary: 'bg-blue-500 hover:bg-blue-600 text-white focus:ring-blue-300',
secondary: 'bg-gray-500 hover:bg-gray-600 text-white focus:ring-gray-300',
outline: 'border-2 border-blue-500 text-blue-500 hover:bg-blue-50 focus:ring-blue-300',
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
}
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`}
onClick={onClick}
>
{children}
</button>
)
}
最佳实践
1. 使用Class Variance Authority (CVA)
对于复杂的组件变体,使用cva以获得更好的类型安全性:
import { cva, type VariantProps } from 'class-variance-authority'
const button = cva(
// 基础类
'font-semibold rounded transition-colors focus:ring-2',
{
variants: {
intent: {
primary: 'bg-blue-500 hover:bg-blue-600 text-white',
secondary: 'bg-gray-500 hover:bg-gray-600 text-white',
danger: 'bg-red-500 hover:bg-red-600 text-white',
},
size: {
small: 'text-sm px-3 py-1.5',
medium: 'text-base px-4 py-2',
large: 'text-lg px-6 py-3',
},
disabled: {
true: 'opacity-50 cursor-not-allowed',
},
},
compoundVariants: [
{
intent: 'primary',
size: 'medium',
className: 'uppercase',
},
],
defaultVariants: {
intent: 'primary',
size: 'medium',
},
}
)
interface ButtonProps extends VariantProps<typeof button> {
children: React.ReactNode
}
export function Button({ intent, size, disabled, children }: ButtonProps) {
return (
<button className={button({ intent, size, disabled })}>
{children}
</button>
)
}
2. 使用clsx/cn进行条件类
高效组合多个类字符串:
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 用法
<div className={cn(
'base-classes',
isActive && 'active-classes',
isDisabled && 'disabled-classes',
className // 允许属性覆盖
)} />
3. 创建复合组件
构建灵活的组件API:
// Card.tsx
interface CardProps {
children: React.ReactNode
className?: string
}
export function Card({ children, className }: CardProps) {
return (
<div className={cn(
'bg-white rounded-lg shadow-md overflow-hidden',
className
)}>
{children}
</div>
)
}
Card.Header = function CardHeader({ children, className }: CardProps) {
return (
<div className={cn('px-6 py-4 border-b border-gray-200', className)}>
{children}
</div>
)
}
Card.Body = function CardBody({ children, className }: CardProps) {
return (
<div className={cn('px-6 py-4', className)}>
{children}
</div>
)
}
Card.Footer = function CardFooter({ children, className }: CardProps) {
return (
<div className={cn('px-6 py-4 bg-gray-50 border-t border-gray-200', className)}>
{children}
</div>
)
}
// 用法
<Card>
<Card.Header>
<h2 className="text-xl font-bold">标题</h2>
</Card.Header>
<Card.Body>
<p>内容放在这里</p>
</Card.Body>
<Card.Footer>
<button>操作</button>
</Card.Footer>
</Card>
4. 谨慎使用@apply
仅对真正可重用的基础样式使用@apply:
/* globals.css */
@layer components {
.btn {
@apply px-4 py-2 rounded font-semibold transition-colors;
}
.btn-primary {
@apply bg-blue-500 hover:bg-blue-600 text-white;
}
.btn-secondary {
@apply bg-gray-500 hover:bg-gray-600 text-white;
}
}
注意: 优先使用组件抽象而非@apply,以保持Tailwind的实用程序优先优势。
5. 设计系统集成
创建集中式设计系统:
// design-system/index.ts
export const colors = {
primary: 'blue-500',
secondary: 'gray-500',
success: 'green-500',
danger: 'red-500',
warning: 'yellow-500',
}
export const spacing = {
xs: '1',
sm: '2',
md: '4',
lg: '6',
xl: '8',
}
export const typography = {
heading: 'font-bold tracking-tight',
body: 'font-normal leading-relaxed',
caption: 'text-sm text-gray-600',
}
// 在组件中使用
import { colors, spacing } from '@/design-system'
<button className={`bg-${colors.primary} p-${spacing.md}`}>
点击我
</button>
示例
表单输入组件
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helperText?: string
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, className, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
)}
<input
ref={ref}
className={cn(
'w-full px-3 py-2 border rounded-md',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
'transition-colors',
error
? 'border-red-500 focus:ring-red-300'
: 'border-gray-300 focus:ring-blue-300',
props.disabled && 'bg-gray-100 cursor-not-allowed',
className
)}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
{helperText && !error && (
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
模态组件
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
interface ModalProps {
isOpen: boolean
onClose: () => void
children: React.ReactNode
title?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
}
export function Modal({
isOpen,
onClose,
children,
title,
size = 'md'
}: ModalProps) {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
}, [isOpen])
if (!isOpen) return null
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
}
return createPortal(
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* 背景层 */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={onClose}
/>
{/* 模态 */}
<div className="flex min-h-screen items-center justify-center p-4">
<div
className={cn(
'relative bg-white rounded-lg shadow-xl',
'w-full transform transition-all',
sizeClasses[size]
)}
>
{/* 头部 */}
{title && (
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold">{title}</h2>
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{/* 内容 */}
<div className="px-6 py-4">
{children}
</div>
</div>
</div>
</div>,
document.body
)
}
下拉菜单组件
import { useState, useRef, useEffect } from 'react'
import { cn } from '@/lib/utils'
interface DropdownProps {
trigger: React.ReactNode
children: React.ReactNode
align?: 'left' | 'right'
}
export function Dropdown({ trigger, children, align = 'left' }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
return (
<div className="relative inline-block" ref={dropdownRef}>
<div onClick={() => setIsOpen(!isOpen)}>
{trigger}
</div>
{isOpen && (
<div className={cn(
'absolute z-50 mt-2 w-56',
'bg-white rounded-md shadow-lg',
'border border-gray-200',
'py-1',
align === 'right' ? 'right-0' : 'left-0'
)}>
{children}
</div>
)}
</div>
)
}
Dropdown.Item = function DropdownItem({
children,
onClick
}: {
children: React.ReactNode
onClick?: () => void
}) {
return (
<button
className={cn(
'w-full text-left px-4 py-2',
'hover:bg-gray-100',
'transition-colors'
)}
onClick={onClick}
>
{children}
</button>
)
}
常见模式
加载状态
export function LoadingButton({
isLoading,
children,
...props
}: ButtonProps & { isLoading?: boolean }) {
return (
<button
className={cn(
'relative',
isLoading && 'cursor-not-allowed opacity-70'
)}
disabled={isLoading}
{...props}
>
{isLoading && (
<span className="absolute inset-0 flex items-center justify-center">
<svg className="animate-spin h-5 w-5" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</span>
)}
<span className={cn(isLoading && 'invisible')}>
{children}
</span>
</button>
)
}
徽章组件
const badge = cva(
'inline-flex items-center rounded-full font-medium',
{
variants: {
variant: {
default: 'bg-gray-100 text-gray-800',
primary: 'bg-blue-100 text-blue-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
danger: 'bg-red-100 text-red-800',
},
size: {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-sm',
lg: 'px-3 py-1.5 text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
export function Badge({ variant, size, children }: VariantProps<typeof badge> & { children: React.ReactNode }) {
return (
<span className={badge({ variant, size })}>
{children}
</span>
)
}
反模式
❌ 不要过度使用@apply
/* 不好:重新创建Bootstrap */
.btn {
@apply px-4 py-2 rounded font-semibold transition-colors focus:ring-2;
}
.btn-primary {
@apply bg-blue-500 hover:bg-blue-600 text-white focus:ring-blue-300;
}
.btn-secondary {
@apply bg-gray-500 hover:bg-gray-600 text-white focus:ring-gray-300;
}
.btn-sm { @apply px-3 py-1 text-sm; }
.btn-lg { @apply px-6 py-3 text-lg; }
/* 好:使用组件抽象替代 */
❌ 不要创建过于通用的组件
// 不好:太灵活,失去Tailwind优势
<Box padding="4" margin="2" bg="blue" />
// 好:语义化组件
<Card className="p-4 m-2 bg-blue-500" />
❌ 不要忽略属性覆盖
// 不好:无法覆盖样式
export function Button({ children }: { children: React.ReactNode }) {
return <button className="bg-blue-500 px-4 py-2">{children}</button>
}
// 好:接受className属性
export function Button({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<button className={cn('bg-blue-500 px-4 py-2', className)}>
{children}
</button>
)
}
相关技能
- tailwind-utility-classes:有效使用Tailwind的实用程序类
- tailwind-configuration:自定义Tailwind配置和主题
- tailwind-responsive-design:构建响应式组件模式