Files
2026-07-13 21:35:49 +08:00

8.1 KiB
Raw Permalink Blame History

Tailwind Design System: Advanced Patterns

高级 Tailwind CSS v4 模式,包括动画、暗色模式主题化、自定义工具类、主题修饰符、命名空间覆盖,以及 v3 到 v4 的迁移清单。

Pattern 5: Native CSS Animations (v4)

/* 在你的 CSS 文件中 —— 用于入场动画的原生 @starting-style */
@theme {
  --animate-dialog-in: dialog-fade-in 0.2s ease-out;
  --animate-dialog-out: dialog-fade-out 0.15s ease-in;
}

@keyframes dialog-fade-in {
  from {
    opacity: 0;
    transform: scale(0.95) translateY(-0.5rem);
  }
  to {
    opacity: 1;
    transform: scale(1) translateY(0);
  }
}

@keyframes dialog-fade-out {
  from {
    opacity: 1;
    transform: scale(1) translateY(0);
  }
  to {
    opacity: 0;
    transform: scale(0.95) translateY(-0.5rem);
  }
}

/* 使用 @starting-style 实现原生 popover 动画 */
[popover] {
  transition:
    opacity 0.2s,
    transform 0.2s,
    display 0.2s allow-discrete;
  opacity: 0;
  transform: scale(0.95);
}

[popover]:popover-open {
  opacity: 1;
  transform: scale(1);
}

@starting-style {
  [popover]:popover-open {
    opacity: 0;
    transform: scale(0.95);
  }
}
// components/ui/dialog.tsx - 使用原生 popover API
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cn } from '@/lib/utils'

const DialogPortal = DialogPrimitive.Portal

export function DialogOverlay({
  className,
  ref,
  ...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
  ref?: React.Ref<HTMLDivElement>
}) {
  return (
    <DialogPrimitive.Overlay
      ref={ref}
      className={cn(
        'fixed inset-0 z-50 bg-black/80',
        'data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out',
        className
      )}
      {...props}
    />
  )
}

export function DialogContent({
  className,
  children,
  ref,
  ...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
  ref?: React.Ref<HTMLDivElement>
}) {
  return (
    <DialogPortal>
      <DialogOverlay />
      <DialogPrimitive.Content
        ref={ref}
        className={cn(
          'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border bg-background p-6 shadow-lg sm:rounded-lg',
          'data-[state=open]:animate-dialog-in data-[state=closed]:animate-dialog-out',
          className
        )}
        {...props}
      >
        {children}
      </DialogPrimitive.Content>
    </DialogPortal>
  )
}

Pattern 6: Dark Mode with CSS (v4)

// providers/ThemeProvider.tsx - 为 v4 简化
'use client'

import { createContext, useContext, useEffect, useState } from 'react'

type Theme = 'dark' | 'light' | 'system'

interface ThemeContextType {
  theme: Theme
  setTheme: (theme: Theme) => void
  resolvedTheme: 'dark' | 'light'
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined)

export function ThemeProvider({
  children,
  defaultTheme = 'system',
  storageKey = 'theme',
}: {
  children: React.ReactNode
  defaultTheme?: Theme
  storageKey?: string
}) {
  const [theme, setTheme] = useState<Theme>(defaultTheme)
  const [resolvedTheme, setResolvedTheme] = useState<'dark' | 'light'>('light')

  useEffect(() => {
    const stored = localStorage.getItem(storageKey) as Theme | null
    if (stored) setTheme(stored)
  }, [storageKey])

  useEffect(() => {
    const root = document.documentElement
    root.classList.remove('light', 'dark')

    const resolved = theme === 'system'
      ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
      : theme

    root.classList.add(resolved)
    setResolvedTheme(resolved)

    // 更新移动端浏览器的 meta theme-color
    const metaThemeColor = document.querySelector('meta[name="theme-color"]')
    if (metaThemeColor) {
      metaThemeColor.setAttribute('content', resolved === 'dark' ? '#09090b' : '#ffffff')
    }
  }, [theme])

  return (
    <ThemeContext.Provider value={{
      theme,
      setTheme: (newTheme) => {
        localStorage.setItem(storageKey, newTheme)
        setTheme(newTheme)
      },
      resolvedTheme,
    }}>
      {children}
    </ThemeContext.Provider>
  )
}

export const useTheme = () => {
  const context = useContext(ThemeContext)
  if (!context) throw new Error('useTheme must be used within ThemeProvider')
  return context
}

// components/ThemeToggle.tsx
import { Moon, Sun } from 'lucide-react'
import { useTheme } from '@/providers/ThemeProvider'

export function ThemeToggle() {
  const { resolvedTheme, setTheme } = useTheme()

  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
    >
      <Sun className="size-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
      <Moon className="absolute size-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
      <span className="sr-only">Toggle theme</span>
    </Button>
  )
}

高级 v4 模式

使用 @utility 定义自定义工具类

定义可复用的自定义工具类:

/* 装饰线的自定义工具类 */
@utility line-t {
  @apply relative before:absolute before:top-0 before:-left-[100vw] before:h-px before:w-[200vw] before:bg-gray-950/5 dark:before:bg-white/10;
}

/* 文本渐变色的自定义工具类 */
@utility text-gradient {
  @apply bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent;
}

主题修饰符

/* 在引用其他 CSS 变量时使用 @theme inline */
@theme inline {
  --font-sans: var(--font-inter), system-ui;
}

/* 使用 @theme static 始终生成 CSS 变量(即使未被使用时也生成) */
@theme static {
  --color-brand: oklch(65% 0.15 240);
}

/* 带主题选项的导入 */
@import "tailwindcss" theme(static);

命名空间覆盖

@theme {
  /* 清除所有默认颜色并定义你自己的颜色 */
  --color-*: initial;
  --color-white: #fff;
  --color-black: #000;
  --color-primary: oklch(45% 0.2 260);
  --color-secondary: oklch(65% 0.15 200);

  /* 为最小化设置清除所有默认值 */
  /* --*: initial; */
}

半透明颜色变体

@theme {
  /* 使用 color-mix() 创建 alpha 变体 */
  --color-primary-50: color-mix(in oklab, var(--color-primary) 5%, transparent);
  --color-primary-100: color-mix(
    in oklab,
    var(--color-primary) 10%,
    transparent
  );
  --color-primary-200: color-mix(
    in oklab,
    var(--color-primary) 20%,
    transparent
  );
}

容器查询

@theme {
  --container-xs: 20rem;
  --container-sm: 24rem;
  --container-md: 28rem;
  --container-lg: 32rem;
}

v3 到 v4 迁移清单

  • tailwind.config.ts 替换为 CSS @theme
  • @tailwind base/components/utilities 改为 @import "tailwindcss"
  • 将颜色定义移至 @theme { --color-*: value }
  • darkMode: "class" 替换为 @custom-variant dark
  • @keyframes 移入 @theme 块内部(确保 keyframes 随主题一起输出)
  • require("tailwindcss-animate") 替换为原生 CSS 动画
  • h-10 w-10 更新为 size-10(新工具类)
  • 移除 forwardRefReact 19 将 ref 作为 prop 传递)
  • 考虑使用 OKLCH 颜色以获得更好的颜色感知
  • 将自定义插件替换为 @utility 指令

最佳实践

推荐做法

  • 使用 @theme —— CSS 优先配置是 v4 的核心模式
  • 使用 OKLCH 颜色 —— 比 HSL 具有更好的感知均匀性
  • 结合 CVA 进行组合 —— 类型安全的变体
  • 使用语义化令牌 —— 使用 bg-primary 而非 bg-blue-500
  • 使用 size-* —— w-* h-* 的新简写形式
  • 增加无障碍支持 —— ARIA 属性、焦点状态

避免做法

  • 不要使用 tailwind.config.ts —— 改用 CSS @theme
  • 不要使用 @tailwind 指令 —— 改用 @import "tailwindcss"
  • 不要使用 forwardRef —— React 19 将 ref 作为 prop 传递
  • 不要使用任意值 —— 改用扩展 @theme
  • 不要硬编码颜色 —— 使用语义化令牌
  • 不要忘记暗色模式 —— 测试两种主题