要点
Tailwind 把常用 CSS 规则表达为类名。布局仍受盒模型、可用宽度和继承规则约束;加类名不能替代理解这些规则。
样式系统的验收标准
- 把颜色、间距、圆角等常用值整理成语义令牌,例如“危险操作”而不是“红色 500”
- 在最窄可用宽度下确认文字、输入框和按钮仍然可读可点
- 切换浅色与深色主题,检查错误、禁用、焦点和正文对比是否都还成立
- 对比生产构建前后的 CSS 产物,并记录环境与测量条件
容易卡住的地方
- 按屏幕宽度而不是按内容决定何时换行
- 在类名里拼接动态片段,构建时被整段丢掉
- 用 utility class 的数量推断最终 CSS 更小或页面更快
概述
本章讲 Tailwind CSS 4 在 Next.js 项目里的接入方式与常见边界。类名把设计决策表达出来,但盒模型、可用宽度和继承规则仍然生效;可读性、可访问性与构建体积要靠真实页面和实际交互来验证。
Tailwind CSS 4 项目配置
接入方式(CSS-first)
Tailwind v4 采用了 CSS-first 配置:create-next-app 集成后项目里不再生成 tailwind.config.ts,入口是 globals.css 中的一行 @import "tailwindcss"。如需在已有 v4 项目手动添加:
# 创建项目时自动集成 Tailwind CSS(推荐)
npx create-next-app@latest my-tailwind-app --typescript --tailwind --eslint --app
1. 已有项目手动升级到 v4:使用官方迁移工具(会自动改写配置文件)
npx @tailwindcss/upgrade
v4 主线配置:globals.css 中的 @theme 与 @source
v4 中设计令牌用 @theme 定义(生成 text-brand-500 等工具类),额外扫描目录用 @source 声明,暗色模式用自定义 variant:
/* app/globals.css */
@import "tailwindcss";
/* 额外内容源(node_modules 里被类名扫描遗漏的目录) */
@source "../node_modules/@my-company/ui/dist";
/* 设计令牌:替代 v3 的 theme.extend */
@theme {
/* 颜色:生成 bg-primary-600 / text-primary-600 等类 */
--color-primary-50: oklch(0.97 0.01 255);
--color-primary-600: oklch(0.55 0.2 258);
--color-primary-700: oklch(0.48 0.2 258);
/* 字体 */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
/* 断点 */
--breakpoint-3xl: 1600px;
/* 动画与关键帧 */
--animate-fade-in: fadeIn 0.5s ease-in-out;
--animate-slide-up: slideUp 0.3s ease-out;
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes slideUp {
0% { transform: translateY(10px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
}
/* class 策略暗色模式:dark: 变体跟随 html.dark */
@custom-variant dark (&:where(.dark, .dark *));
JS 插件(@tailwindcss/typography 等)在 v4 中改用 @plugin 指令引入:@plugin "@tailwindcss/typography";。
遗留项目对照:v3 的 tailwind.config.ts(仅供迁移参考)
以下写法属于 v3 的 content/theme.extend 方案,在 v4 中不再使用;遇到维护中的旧项目时可以对照理解,新项目请用上面的 CSS-first 配置。
// tailwind.config.ts —— 仅 v3 遗留项目使用
import type { Config } from 'tailwindcss'
const config: Config = {
// 内容源配置
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
// 主题配置
theme: {
extend: {
// 颜色系统
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
950: '#030712',
},
},
// 字体系统
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
display: ['Cal Sans', 'ui-sans-serif', 'system-ui'],
},
// 字号系统
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],
lg: ['1.125rem', { lineHeight: '1.75rem' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
'5xl': ['3rem', { lineHeight: '1' }],
'6xl': ['3.75rem', { lineHeight: '1' }],
},
// 间距系统
spacing: {
'18': '4.5rem',
'88': '22rem',
'128': '32rem',
},
// 圆角系统
borderRadius: {
'4xl': '2rem',
},
// 阴影系统
boxShadow: {
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
'medium': '0 4px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
'hard': '0 10px 40px -10px rgba(0, 0, 0, 0.2), 0 2px 10px -2px rgba(0, 0, 0, 0.04)',
},
// 动画系统
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
'scale-in': 'scaleIn 0.2s ease-out',
'bounce-soft': 'bounceSoft 0.6s ease-in-out',
},
// 关键帧
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideDown: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
scaleIn: {
'0%': { transform: 'scale(0.9)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' },
},
bounceSoft: {
'0%, 20%, 53%, 80%, 100%': { transform: 'translateY(0)' },
'40%, 43%': { transform: 'translateY(-8px)' },
'70%': { transform: 'translateY(-4px)' },
'90%': { transform: 'translateY(-2px)' },
},
},
// 断点系统
screens: {
'3xl': '1600px',
},
},
},
// 插件配置
plugins: [
// 表单插件
require('@tailwindcss/forms')({
strategy: 'class',
}),
// 排版插件
require('@tailwindcss/typography'),
// 容器查询插件
require('@tailwindcss/container-queries'),
// 自定义插件
function({ addUtilities, theme }: any) {
// 添加自定义工具类
addUtilities({
'.text-shadow': {
textShadow: '0 2px 4px rgba(0,0,0,0.10)',
},
'.text-shadow-md': {
textShadow: '0 4px 8px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.08)',
},
'.backface-hidden': {
backfaceVisibility: 'hidden',
},
'.transform-gpu': {
transform: 'translate3d(0, 0, 0)',
},
})
// 添加渐变文字工具类
addUtilities({
'.text-gradient': {
background: 'linear-gradient(to right, #3b82f6, #8b5cf6)',
'-webkit-background-clip': 'text',
'-webkit-text-fill-color': 'transparent',
'background-clip': 'text',
},
'.text-gradient-rainbow': {
background: 'linear-gradient(to right, #ef4444, #f59e0b, #10b981, #3b82f6, #8b5cf6)',
'-webkit-background-clip': 'text',
'-webkit-text-fill-color': 'transparent',
'background-clip': 'text',
},
})
},
],
// 前缀配置
prefix: '',
// 重要标记配置
important: false,
// 分离器配置
separator: ':',
// 核心插件配置
corePlugins: {
preflight: true,
},
// 暗色模式配置
darkMode: ['class'],
}
export default config
从 v3 迁移时注意:separator、corePlugins.preflight 开关等项在 v4 中已被移除或改为 CSS 指令;@tailwindcss/container-queries 与 @tailwindcss/forms 的功能在 v4 内置或以 CSS 方式提供。
基础样式模式
文本样式系统
// src/components/Typography.tsx
import { cn } from '@/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
// 标题样式变体
const headingVariants = cva('font-semibold text-gray-900 dark:text-gray-100', {
variants: {
size: {
h1: 'text-4xl md:text-5xl lg:text-6xl',
h2: 'text-3xl md:text-4xl lg:text-5xl',
h3: 'text-2xl md:text-3xl lg:text-4xl',
h4: 'text-xl md:text-2xl lg:text-3xl',
h5: 'text-lg md:text-xl lg:text-2xl',
h6: 'text-base md:text-lg lg:text-xl',
},
weight: {
light: 'font-light',
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
},
align: {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
},
},
defaultVariants: {
size: 'h2',
weight: 'semibold',
align: 'left',
},
})
// 正文样式变体
const textVariants = cva('text-gray-700 dark:text-gray-300', {
variants: {
size: {
xs: 'text-xs',
sm: 'text-sm',
base: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
'2xl': 'text-2xl',
'3xl': 'text-3xl',
},
weight: {
light: 'font-light',
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
},
color: {
default: 'text-gray-700 dark:text-gray-300',
muted: 'text-gray-500 dark:text-gray-400',
primary: 'text-primary-600 dark:text-primary-400',
secondary: 'text-secondary-600 dark:text-secondary-400',
accent: 'text-accent-600 dark:text-accent-400',
},
},
defaultVariants: {
size: 'base',
weight: 'normal',
color: 'default',
},
})
interface HeadingProps extends VariantProps<typeof headingVariants> {
children: React.ReactNode
className?: string
}
export function Heading({ children, className, size, weight, align }: HeadingProps) {
const Tag = size as keyof React.JSX.IntrinsicElements
return (
<Tag className={cn(headingVariants({ size, weight, align }), className)}>
{children}
</Tag>
)
}
interface TextProps extends VariantProps<typeof textVariants> {
children: React.ReactNode
className?: string
as?: keyof React.JSX.IntrinsicElements
}
export function Text({ children, className, size, weight, color, as: Tag = 'p' }: TextProps) {
return (
<Tag className={cn(textVariants({ size, weight, color }), className)}>
{children}
</Tag>
)
}
按钮样式系统
// src/components/Button.tsx
import { forwardRef } from 'react'
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-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none',
{
variants: {
variant: {
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700',
outline: 'border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-50 focus:ring-primary-500 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800',
ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500 dark:text-gray-300 dark:hover:bg-gray-800',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
success: 'bg-green-600 text-white hover:bg-green-700 focus:ring-green-500',
warning: 'bg-yellow-500 text-white hover:bg-yellow-600 focus:ring-yellow-500',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 py-2 text-sm',
lg: 'h-12 px-6 text-base',
xl: 'h-14 px-8 text-lg',
},
fullWidth: {
true: 'w-full',
false: 'w-auto',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
fullWidth: false,
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean
icon?: React.ReactNode
iconPosition?: 'left' | 'right'
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, fullWidth, loading, icon, iconPosition = 'left', children, disabled, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, fullWidth }), className)}
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<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>
)}
{icon && iconPosition === 'left' && !loading && (
<span className="mr-2">{icon}</span>
)}
{children}
{icon && iconPosition === 'right' && (
<span className="ml-2">{icon}</span>
)}
</button>
)
}
)
Button.displayName = 'Button'
卡片组件样式
// src/components/Card.tsx
import { forwardRef } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
// 卡片样式变体
const cardVariants = cva('rounded-xl border bg-white shadow-soft dark:bg-gray-900 dark:border-gray-800', {
variants: {
variant: {
default: 'border-gray-200 dark:border-gray-800',
elevated: 'border-gray-100 shadow-medium dark:border-gray-700',
outlined: 'border-2 border-gray-300 shadow-none dark:border-gray-600',
ghost: 'border-transparent shadow-none bg-transparent',
},
padding: {
none: 'p-0',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
xl: 'p-10',
},
hover: {
true: 'transition-all duration-200 hover:shadow-medium hover:-translate-y-1',
false: '',
},
},
defaultVariants: {
variant: 'default',
padding: 'md',
hover: false,
},
})
export interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {}
export const Card = forwardRef<HTMLDivElement, CardProps>(
({ className, variant, padding, hover, ...props }, ref) => (
<div
ref={ref}
className={cn(cardVariants({ variant, padding, hover }), className)}
{...props}
/>
)
)
Card.displayName = 'Card'
// 卡片头部
export const CardHeader = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
// 卡片标题
export const CardTitle = forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
// 卡片描述
export const CardDescription = forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-gray-500 dark:text-gray-400', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
// 卡片内容
export const CardContent = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
// 卡片底部
export const CardFooter = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
响应式设计系统
移动优先布局
// src/components/ResponsiveGrid.tsx
import { cn } from '@/lib/utils'
interface ResponsiveGridProps {
children: React.ReactNode
className?: string
cols?: {
sm?: number
md?: number
lg?: number
xl?: number
'2xl'?: number
}
gap?: {
sm?: number
md?: number
lg?: number
xl?: number
}
}
export function ResponsiveGrid({
children,
className,
cols = { sm: 1, md: 2, lg: 3, xl: 4 },
gap = { sm: 4, md: 6, lg: 8 }
}: ResponsiveGridProps) {
const gridClasses = cn(
'grid',
cols.sm && `grid-cols-${cols.sm}`,
cols.md && `md:grid-cols-${cols.md}`,
cols.lg && `lg:grid-cols-${cols.lg}`,
cols.xl && `xl:grid-cols-${cols.xl}`,
gap.sm && `gap-${gap.sm}`,
gap.md && `md:gap-${gap.md}`,
gap.lg && `lg:gap-${gap.lg}`,
gap.xl && `xl:gap-${gap.xl}`,
className
)
return <div className={gridClasses}>{children}</div>
}
// 使用示例
export function ProductGrid() {
return (
<ResponsiveGrid
cols={{ sm: 1, md: 2, lg: 3, xl: 4 }}
gap={{ sm: 4, md: 6, lg: 8 }}
className="py-8"
>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</ResponsiveGrid>
)
}
响应式容器
// src/components/Container.tsx
import { cn } from '@/lib/utils'
interface ContainerProps {
children: React.ReactNode
className?: string
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
centered?: boolean
}
export function Container({
children,
className,
size = 'lg',
centered = true
}: ContainerProps) {
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-7xl',
xl: 'max-w-screen-xl',
full: 'max-w-full'
}
return (
<div
className={cn(
'w-full px-4 sm:px-6 lg:px-8',
sizeClasses[size],
centered && 'mx-auto',
className
)}
>
{children}
</div>
)
}
响应式图片
// src/components/ResponsiveImage.tsx
import Image from 'next/image'
import { useState } from 'react'
interface ResponsiveImageProps {
src: string
alt: string
width: number
height: number
priority?: boolean
className?: string
sizes?: string
quality?: number
fill?: boolean
}
export function ResponsiveImage({
src,
alt,
width,
height,
priority = false,
className = '',
sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw',
quality = 75,
fill = false
}: ResponsiveImageProps) {
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(false)
if (error) {
return (
<div className={cn(
'flex items-center justify-center bg-gray-200 dark:bg-gray-800 text-gray-500',
fill ? 'absolute inset-0' : 'w-full h-full',
className
)}>
<span className="text-sm">图片加载失败</span>
</div>
)
}
return (
<div className={cn('relative overflow-hidden', className)}>
<Image
src={src}
alt={alt}
width={fill ? undefined : width}
height={fill ? undefined : height}
fill={fill}
priority={priority}
quality={quality}
sizes={sizes}
className={cn(
'transition-opacity duration-300',
isLoading ? 'opacity-0' : 'opacity-100',
fill ? 'object-cover' : 'w-full h-auto'
)}
onLoad={() => setIsLoading(false)}
onError={() => setError(true)}
/>
{isLoading && (
<div className="absolute inset-0 bg-gray-200 dark:bg-gray-800 animate-pulse" />
)}
</div>
)
}
主题系统实现
暗色模式配置
// src/components/ThemeProvider.tsx
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'light' | 'dark' | 'system'
interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
systemTheme: 'light' | 'dark'
effectiveTheme: 'light' | 'dark'
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'ui-theme',
}: {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}) {
const [theme, setTheme] = useState<Theme>(defaultTheme)
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>('light')
// 获取系统主题
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
setSystemTheme(mediaQuery.matches ? 'dark' : 'light')
const handleChange = (e: MediaQueryListEvent) => {
setSystemTheme(e.matches ? 'dark' : 'light')
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
// 从localStorage读取主题设置
useEffect(() => {
const stored = localStorage.getItem(storageKey) as Theme
if (stored) {
setTheme(stored)
}
}, [storageKey])
// 应用主题到HTML元素
useEffect(() => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
let effectiveTheme: 'light' | 'dark'
if (theme === 'system') {
effectiveTheme = systemTheme
} else {
effectiveTheme = theme
}
root.classList.add(effectiveTheme)
}, [theme, systemTheme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
systemTheme,
effectiveTheme: theme === 'system' ? systemTheme : theme,
}
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeContext)
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}
主题切换组件
// src/components/ThemeToggle.tsx
'use client'
import { Moon, Sun, Monitor } from 'lucide-react'
import { useTheme } from './ThemeProvider'
import { Button } from './Button'
export function ThemeToggle() {
const { theme, setTheme, effectiveTheme } = useTheme()
const themes = [
{ value: 'light' as const, label: '浅色', icon: Sun },
{ value: 'dark' as const, label: '深色', icon: Moon },
{ value: 'system' as const, label: '跟随系统', icon: Monitor },
]
return (
<div className="flex items-center space-x-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
{themes.map(({ value, label, icon: Icon }) => (
<Button
key={value}
variant={theme === value ? 'primary' : 'ghost'}
size="sm"
onClick={() => setTheme(value)}
className="h-8 px-2"
title={label}
>
<Icon className="h-4 w-4" />
</Button>
))}
</div>
)
}
高级样式模式
渐变和图案
// src/components/GradientBackground.tsx
import { cn } from '@/lib/utils'
interface GradientBackgroundProps {
children: React.ReactNode
className?: string
variant?: 'linear' | 'radial' | 'conic'
colors: string[]
direction?: string
animated?: boolean
}
export function GradientBackground({
children,
className,
variant = 'linear',
colors,
direction = 'to right',
animated = false
}: GradientBackgroundProps) {
// 注意:不要写 `bg-gradient-${direction}` 这类动态拼接的类名——
// Tailwind 静态扫描看不到完整字面量,样式不会生成。
// 运行时才知道的渐变参数用 inline style 表达。
const gradientStyle: CSSProperties =
variant === 'linear'
? { backgroundImage: `linear-gradient(${direction}, ${colors.join(', ')})` }
: variant === 'radial'
? { backgroundImage: `radial-gradient(circle, ${colors.join(', ')})` }
: { backgroundImage: `conic-gradient(from 0deg, ${colors.join(', ')})` }
return (
<div className="relative overflow-hidden">
<div
className={cn('absolute inset-0', animated && 'animate-fade-in', className)}
style={gradientStyle}
/>
<div className="relative z-10">
{children}
</div>
</div>
)
}
// 使用示例
export function HeroSection() {
return (
<GradientBackground
colors={['#3b82f6', '#8b5cf6', '#ec4899']}
direction="to bottom right"
animated
>
<div className="py-20 text-center text-white">
<h1 className="text-5xl font-bold mb-6">欢迎来到未来</h1>
<p className="text-xl opacity-90">体验现代化的Web开发</p>
</div>
</GradientBackground>
)
}
玻璃态效果
// src/components/GlassEffect.tsx
import { cn } from '@/lib/utils'
interface GlassEffectProps {
children: React.ReactNode
className?: string
blur?: 'sm' | 'md' | 'lg' | 'xl'
opacity?: 'low' | 'medium' | 'high'
border?: boolean
}
export function GlassEffect({
children,
className,
blur = 'md',
opacity = 'medium',
border = true
}: GlassEffectProps) {
const blurClasses = {
sm: 'backdrop-blur-sm',
md: 'backdrop-blur-md',
lg: 'backdrop-blur-lg',
xl: 'backdrop-blur-xl'
}
const opacityClasses = {
low: 'bg-white/10 dark:bg-black/10',
medium: 'bg-white/20 dark:bg-black/20',
high: 'bg-white/30 dark:bg-black/30'
}
return (
<div
className={cn(
'relative',
blurClasses[blur],
opacityClasses[opacity],
border && 'border border-white/20 dark:border-white/10',
'rounded-xl',
className
)}
>
{children}
</div>
)
}
性能优化策略
样式分离和关键CSS
App Router 中 next/head 已不可用:把关键 CSS 写进根布局引入的样式文件,或用 React 19 支持的 <style> 标签直接内联在 Server Component 中输出。
// src/components/CriticalStyles.tsx(App Router 写法,无需 next/head)
import type { CSSProperties } from 'react'
export function CriticalStyles() {
const criticalCSS = `
/* 关键路径样式 */
body {
margin: 0;
padding: 0;
font-family: 'Inter', system-ui, sans-serif;
line-height: 1.6;
}
.loading-skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 隐藏未加载的图片 */
img:not([src]) {
visibility: hidden;
}
`
// React 19 允许在组件树任何位置渲染 <style>,它会被提升到 <head>
return <style dangerouslySetInnerHTML={{ __html: criticalCSS }} />
}
动态类名与响应式网格的坑
动态拼接的类名是 Tailwind 最常见的坑。v3 时代靠 purge.safelist 兜底,但 purge 在 v3 就已被 content 取代、v4 彻底移除——现在没有 safelist 选项了。正确做法是写出完整类名字面量,让扫描器能静态命中:
// ❌ 扫描器看不到完整类名,这些样式不会生成
const cls = `bg-${color}-500`
// ✅ 用完整映射表,类名是字面量
const COLORS = {
primary: "bg-primary-600",
danger: "bg-red-500",
} as const
const cls = COLORS[color]
上文 ResponsiveGrid 中 grid-cols-${cols.sm} 的写法同样踩这个坑:传入的列数/间距值应改为从预先写好的完整类名映射表中选取,或直接用 inline style 设置 grid-template-columns 与 gap。
总结
样式系统的验收边界
- 令牌表达语义:颜色、间距和圆角等常用值以用途命名,例如“危险操作”而非“红色 500”;组件变体从这些令牌组合,避免相似页面逐处复制不一致的类串。
- 响应式从内容开始:先在最窄可用宽度完成阅读、输入和操作,再为内容确实拥挤的断点调整布局。只拖动浏览器并看到元素没有溢出,不足以证明触控和文字缩放可用。
- 主题切换不闪烁也不丢语义:初始化主题前避免错误主题短暂显示;前景/背景、边框、焦点和状态色都要在每个主题中保持足够对比,不能只切换页面背景。
- 变体输入受约束:组件公开有限的
size、tone、loading等变体;未知输入回退到安全样式或由类型与运行时校验拒绝,不能让任意类名绕过设计系统。 - 性能从产物和体验测量:检查生产构建的 CSS、关键页面加载与交互;不要仅根据“使用了 utility class”推断 CSS 更小或页面更快。
学习成果验收
- [ ] 为按钮、提示和表单错误定义语义令牌;更改一个令牌后,确认相关组件一致更新且无无关页面受影响。
- [ ] 在窄屏、宽屏、文字放大和键盘导航下完成关键表单,记录溢出、焦点与可读性结果。
- [ ] 切换浅色和深色主题并刷新页面,检查没有明显主题闪烁,错误、禁用、焦点和正文对比都可辨认。
- [ ] 传入一个未定义变体,确认类型检查或运行时回退能阻止不可预测的样式。
- [ ] 比较生产构建前后关键页面的 CSS 产物和用户可见加载结果,记录环境与测量条件。
样式工具只负责表达设计决策;可读性、可访问性、主题切换和构建体积仍需要在真实页面和实际交互中验证。