Next.js 样式与 Tailwind

要点

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。

总结

样式系统的验收边界

  1. 令牌表达语义:颜色、间距和圆角等常用值以用途命名,例如“危险操作”而非“红色 500”;组件变体从这些令牌组合,避免相似页面逐处复制不一致的类串。
  2. 响应式从内容开始:先在最窄可用宽度完成阅读、输入和操作,再为内容确实拥挤的断点调整布局。只拖动浏览器并看到元素没有溢出,不足以证明触控和文字缩放可用。
  3. 主题切换不闪烁也不丢语义:初始化主题前避免错误主题短暂显示;前景/背景、边框、焦点和状态色都要在每个主题中保持足够对比,不能只切换页面背景。
  4. 变体输入受约束:组件公开有限的 size、tone、loading 等变体;未知输入回退到安全样式或由类型与运行时校验拒绝,不能让任意类名绕过设计系统。
  5. 性能从产物和体验测量:检查生产构建的 CSS、关键页面加载与交互;不要仅根据“使用了 utility class”推断 CSS 更小或页面更快。

学习成果验收

  • [ ] 为按钮、提示和表单错误定义语义令牌;更改一个令牌后,确认相关组件一致更新且无无关页面受影响。
  • [ ] 在窄屏、宽屏、文字放大和键盘导航下完成关键表单,记录溢出、焦点与可读性结果。
  • [ ] 切换浅色和深色主题并刷新页面,检查没有明显主题闪烁,错误、禁用、焦点和正文对比都可辨认。
  • [ ] 传入一个未定义变体,确认类型检查或运行时回退能阻止不可预测的样式。
  • [ ] 比较生产构建前后关键页面的 CSS 产物和用户可见加载结果,记录环境与测量条件。

样式工具只负责表达设计决策;可读性、可访问性、主题切换和构建体积仍需要在真实页面和实际交互中验证。

相关阅读

暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇