OpenMontage 技能库实战:基于 Tailwind CSS v4 构建可扩展设计系统
【免费下载链接】OpenMontageWorld's first open-source, agentic video production system. 12 production pipelines, 100+ tools, 700+ agent skill and production-knowledge files. Turn your AI coding assistant into a full video production studio.项目地址: https://gitcode.com/GitHub_Trending/op/OpenMontage
本指南以 OpenMontage 仓库中 tailwind-design-system 技能文档 为骨架,系统讲解 Tailwind CSS v4 的 CSS-first 配置范式:从@theme设计令牌、@custom-variant暗黑模式,到 CVA 组件变体、React 19 复合组件、原生动画与 v3→v4 迁移清单。读完本文,你将掌握用 v4 的纯 CSS 方式构建一整套生产级、可访问、响应式的组件库,并能对照仓库内 HyperFrames Tailwind 参考 在固定视口渲染场景下规避 v4 的坑点。
何时使用这套技能
在 OpenMontage 的 Agent 技能体系中,tailwind-design-system属于 skills/INDEX.md 中Design类别下的核心设计技能。按照技能文档的 frontmatter 定义,以下场景应主动调用它:
- 使用 Tailwind v4 创建组件库;
- 以 CSS-first 配置实现设计令牌(design tokens)与主题化;
- 构建响应式、可访问的组件;
- 在代码库中统一 UI 模式;
- 从 Tailwind v3 迁移到 v4;
- 使用原生 CSS 特性搭建暗黑模式。
注意:该技能面向 Tailwind CSS v4(2024+)。对于 v3 项目,应优先参考官方升级指南,再回到本技能落地。
v3 到 v4 的关键变化速查表
技能文档用一张对照表总结了 v4 相对 v3 的范式转移,这是理解全文的基础:
| v3 模式 | v4 模式 |
|---|---|
tailwind.config.ts | @theme写在 CSS 中 |
@tailwind base/components/utilities | @import "tailwindcss" |
darkMode: "class" | @custom-variant dark (&:where(.dark, .dark *)) |
theme.extend.colors | @theme { --color-*: value } |
require("tailwindcss-animate") | CSS@keyframes放进@theme+ 用@starting-style做入场动画 |
一句话概括:配置从 JS 配置文件搬进了 CSS,一切以原生 CSS 变量为中心。
CSS-first 配置:从零搭建完整主题
v4 的核心是“配置即 CSS”。以下是一份完整的app.css示例,它同时定义了语义色板(使用 OKLCH 色彩空间)、圆角令牌、动画令牌与暗黑模式覆盖:
/* app.css - Tailwind v4 CSS-first configuration */ @import "tailwindcss"; /* Define your theme with @theme */ @theme { /* Semantic color tokens using OKLCH for better color perception */ --color-background: oklch(100% 0 0); --color-foreground: oklch(14.5% 0.025 264); --color-primary: oklch(14.5% 0.025 264); --color-primary-foreground: oklch(98% 0.01 264); --color-secondary: oklch(96% 0.01 264); --color-secondary-foreground: oklch(14.5% 0.025 264); --color-muted: oklch(96% 0.01 264); --color-muted-foreground: oklch(46% 0.02 264); --color-accent: oklch(96% 0.01 264); --color-accent-foreground: oklch(14.5% 0.025 264); --color-destructive: oklch(53% 0.22 27); --color-destructive-foreground: oklch(98% 0.01 264); --color-border: oklch(91% 0.01 264); --color-ring: oklch(14.5% 0.025 264); --color-card: oklch(100% 0 0); --color-card-foreground: oklch(14.5% 0.025 264); /* Ring offset for focus states */ --color-ring-offset: oklch(100% 0 0); /* Radius tokens */ --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; --radius-xl: 0.75rem; /* Animation tokens - keyframes inside @theme are output when referenced by --animate-* variables */ --animate-fade-in: fade-in 0.2s ease-out; --animate-fade-out: fade-out 0.2s ease-in; --animate-slide-in: slide-in 0.3s ease-out; --animate-slide-out: slide-out 0.3s ease-in; @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } @keyframes slide-in { from { transform: translateY(-0.5rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } } @keyframes slide-out { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-0.5rem); opacity: 0; } } } /* Dark mode variant - use @custom-variant for class-based dark mode */ @custom-variant dark (&:where(.dark, .dark *)); /* Dark mode theme overrides */ .dark { --color-background: oklch(14.5% 0.025 264); --color-foreground: oklch(98% 0.01 264); --color-primary: oklch(98% 0.01 264); --color-primary-foreground: oklch(14.5% 0.025 264); --color-secondary: oklch(22% 0.02 264); --color-secondary-foreground: oklch(98% 0.01 264); --color-muted: oklch(22% 0.02 264); --color-muted-foreground: oklch(65% 0.02 264); --color-accent: oklch(22% 0.02 264); --color-accent-foreground: oklch(98% 0.01 264); --color-destructive: oklch(42% 0.15 27); --color-destructive-foreground: oklch(98% 0.01 264); --color-border: oklch(22% 0.02 264); --color-ring: oklch(83% 0.02 264); --color-card: oklch(14.5% 0.025 264); --color-card-foreground: oklch(98% 0.01 264); --color-ring-offset: oklch(14.5% 0.025 264); } /* Base styles */ @layer base { * { @apply border-border; } body { @apply bg-background text-foreground antialiased; } }关键点解读
@import "tailwindcss"替代三条@tailwind指令:v4 默认输出 base、components、utilities 三层,无需再手动拆分。@theme即配置:在@theme中声明的--color-*、--radius-*、--animate-*变量会自动生成对应的工具类(如--color-primary→bg-primary、text-primary、border-primary)。@keyframes放入@theme:只有被--animate-*变量引用时才会随主题输出,避免产生无用的关键帧代码。@custom-variant dark恢复 class 暗黑模式:选择器&:where(.dark, .dark *)会同时命中.dark元素及其所有后代,实现整棵子树切换。- OKLCH 语义色板:技能文档推荐优先使用 OKLCH 而非 HSL,因为其感知均匀性更好;前景/背景、主/辅色均围绕同一个色调(264)搭配,保证对比度协调。
设计令牌的三级层次
技能文档给出了清晰的令牌分层模型,这是“可扩展设计系统”的灵魂:
Brand Tokens (abstract) └── Semantic Tokens (purpose) └── Component Tokens (specific) Example: oklch(45% 0.2 260) → --color-primary → bg-primary- Brand Tokens(品牌令牌):最底层抽象,如裸色值
oklch(45% 0.2 260),不绑定任何语义; - Semantic Tokens(语义令牌):把品牌色映射到用途,如
--color-primary; - Component Tokens(组件令牌):语义令牌落到具体组件用法,如
bg-primary。
由此带来的硬性规范是:组件里只写bg-primary,绝不写bg-blue-500。这样当品牌色调整时,只需改动@theme一处。
组件架构与 CVA 变体模式
组件设计遵循“基础样式 → 变体 → 尺寸 → 状态 → 覆盖”的分层架构:
Base styles → Variants → Sizes → States → OverridesPattern 1:CVA(Class Variance Authority)组件
用class-variance-authority实现类型安全的变体组合,是 v4 组件库的标配写法:
// components/ui/button.tsx import { Slot } from '@radix-ui/react-slot' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const buttonVariants = cva( // Base styles - v4 uses native CSS variables 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', outline: 'border border-border bg-background hover:bg-accent hover:text-accent-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-10 px-4 py-2', sm: 'h-9 rounded-md px-3', lg: 'h-11 rounded-md px-8', icon: 'size-10', }, }, defaultVariants: { variant: 'default', size: 'default', }, } ) export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> { asChild?: boolean } // React 19: No forwardRef needed export function Button({ className, variant, size, asChild = false, ref, ...props }: ButtonProps & { ref?: React.Ref<HTMLButtonElement> }) { const Comp = asChild ? Slot : 'button' return ( <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} /> ) } // Usage <Button variant="destructive" size="lg">Delete</Button> <Button variant="outline">Cancel</Button> <Button asChild><Link href="/home">Home</Link></Button>要点:基础样式集中描述“排版、圆角、聚焦环、禁用态”,变体只叠加差异;/90、/80这类透明度修饰符在 v4 中基于color-mix()实现;size-10是 v4 新增的宽高简写;asChild配合 Radix 的Slot让按钮可以渲染为Link等任意元素。
Pattern 2:React 19 复合组件
React 19 中ref成为普通 prop,不再需要forwardRef。技能文档以 Card 系列组件演示复合组件写法:
// components/ui/card.tsx import { cn } from '@/lib/utils' // React 19: ref is a regular prop, no forwardRef export function Card({ className, ref, ...props }: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) { return ( <div ref={ref} className={cn( 'rounded-lg border border-border bg-card text-card-foreground shadow-sm', className )} {...props} /> ) } export function CardHeader({ className, ref, ...props }: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) { return ( <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} /> ) } export function CardTitle({ className, ref, ...props }: React.HTMLAttributes<HTMLHeadingElement> & { ref?: React.Ref<HTMLHeadingElement> }) { return ( <h3 ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} /> ) } export function CardDescription({ className, ref, ...props }: React.HTMLAttributes<HTMLParagraphElement> & { ref?: React.Ref<HTMLParagraphElement> }) { return ( <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> ) } export function CardContent({ className, ref, ...props }: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) { return ( <div ref={ref} className={cn('p-6 pt-0', className)} {...props} /> ) } export function CardFooter({ className, ref, ...props }: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) { return ( <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} /> ) } // Usage <Card> <CardHeader> <CardTitle>Account</CardTitle> <CardDescription>Manage your account settings</CardDescription> </CardHeader> <CardContent> <form>...</form> </CardContent> <CardFooter> <Button>Save</Button> </CardFooter> </Card>复合组件把容器、头部、标题、描述、内容、底部按语义拆分成独立导出的子组件,每个子组件通过cn()合并外部传入的className,实现“封闭内部样式、开放覆盖入口”的扩展模型。
Pattern 3:表单组件与校验集成
表单输入框需要同时处理焦点环、错误态与无障碍标注。技能文档展示了Input+Label与 React Hook Form + Zod 的完整组合:
// components/ui/input.tsx import { cn } from '@/lib/utils' export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { error?: string ref?: React.Ref<HTMLInputElement> } export function Input({ className, type, error, ref, ...props }: InputProps) { return ( <div className="relative"> <input type={type} className={cn( 'flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', error && 'border-destructive focus-visible:ring-destructive', className )} ref={ref} aria-invalid={!!error} aria-describedby={error ? `${props.id}-error` : undefined} {...props} /> {error && ( <p id={`${props.id}-error`} className="mt-1 text-sm text-destructive" role="alert" > {error} </p> )} </div> ) } // components/ui/label.tsx import { cva, type VariantProps } from 'class-variance-authority' const labelVariants = cva( 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' ) export function Label({ className, ref, ...props }: React.LabelHTMLAttributes<HTMLLabelElement> & { ref?: React.Ref<HTMLLabelElement> }) { return ( <label ref={ref} className={cn(labelVariants(), className)} {...props} /> ) } // Usage with React Hook Form + Zod import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' const schema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), }) function LoginForm() { const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(schema), }) return ( <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <div className="space-y-2"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" {...register('email')} error={errors.email?.message} /> </div> <div className="space-y-2"> <Label htmlFor="password">Password</Label> <Input id="password" type="password" {...register('password')} error={errors.password?.message} /> </div> <Button type="submit" className="w-full">Sign In</Button> </form> ) }无障碍要点:错误态通过aria-invalid标记输入框,通过aria-describedby把错误消息与输入框关联,错误消息本身使用role="alert";错误出现时边框与焦点环同步切换为destructive语义色。
Pattern 4:响应式网格与容器
把响应式断点抽象成 CVA 变体,可以让调用方用cols与gap两个 prop 完成全部布局控制:
// components/ui/grid.tsx import { cn } from '@/lib/utils' import { cva, type VariantProps } from 'class-variance-authority' const gridVariants = cva('grid', { variants: { cols: { 1: 'grid-cols-1', 2: 'grid-cols-1 sm:grid-cols-2', 3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3', 4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4', 5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5', 6: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6', }, gap: { none: 'gap-0', sm: 'gap-2', md: 'gap-4', lg: 'gap-6', xl: 'gap-8', }, }, defaultVariants: { cols: 3, gap: 'md', }, }) interface GridProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof gridVariants> {} export function Grid({ className, cols, gap, ...props }: GridProps) { return ( <div className={cn(gridVariants({ cols, gap, className }))} {...props} /> ) } // Container component const containerVariants = cva('mx-auto w-full px-4 sm:px-6 lg:px-8', { variants: { size: { sm: 'max-w-screen-sm', md: 'max-w-screen-md', lg: 'max-w-screen-lg', xl: 'max-w-screen-xl', '2xl': 'max-w-screen-2xl', full: 'max-w-full', }, }, defaultVariants: { size: 'xl', }, }) interface ContainerProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof containerVariants> {} export function Container({ className, size, ...props }: ContainerProps) { return ( <div className={cn(containerVariants({ size, className }))} {...props} /> ) } // Usage <Container> <Grid cols={4} gap="lg"> {products.map((product) => ( <ProductCard key={product.id} product={product} /> ))} </Grid> </Container>注意网格变体的设计意图:移动端始终 1 列起步,sm断点升到 2 列,lg断点再升到满列数,容器负责控制最大宽度与水平内边距。
Pattern 5:v4 原生 CSS 动画
v4 用@starting-style与allow-discrete过渡实现了纯 CSS 的入场/出场动画,可以完全替代tailwindcss-animate插件。技能文档以对话框为例,先定义动画令牌:
/* In your CSS file - native @starting-style for entry animations */ @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); } } /* Native popover animations using @starting-style */ [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); } }再结合 Radix Dialog 与data-[state=...]属性选择器驱动动画:
// components/ui/dialog.tsx - Using native 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>// providers/ThemeProvider.tsx - Simplified for 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) // Update meta theme-color for mobile browsers 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> ) }实现要点:system模式通过window.matchMedia('(prefers-color-scheme: dark)')解析;resolvedTheme供需要感知实际明暗的代码使用;切换图标用rotate/scale过渡实现太阳与月亮的交叉淡入淡出;sr-only保证屏幕阅读器可读。
工具函数:cn / focusRing / disabled
组件库的样式合并统一收敛到cn,并把高频状态组合抽成常量:
// lib/utils.ts import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } // Focus ring utility export const focusRing = cn( "focus-visible:outline-none focus-visible:ring-2", "focus-visible:ring-ring focus-visible:ring-offset-2", ); // Disabled utility export const disabled = "disabled:pointer-events-none disabled:opacity-50";cn先用clsx处理条件类名,再用tailwind-merge去重冲突的 Tailwind 类(例如后传入的bg-red-500会正确覆盖前一个bg-blue-500),这也是所有组件能安全接收外部className的前提。
高级 v4 模式
用@utility定义自定义工具类
@utility替代 v3 的addUtilities插件机制,直接在 CSS 中声明可被变体(如hover:、dark:)组合的自定义工具:
/* Custom utility for decorative lines */ @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; } /* Custom utility for text gradients */ @utility text-gradient { @apply bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent; }@theme inline与@theme static
/* Use @theme inline when referencing other CSS variables */ @theme inline { --font-sans: var(--font-inter), system-ui; } /* Use @theme static to always generate CSS variables (even when unused) */ @theme static { --color-brand: oklch(65% 0.15 240); } /* Import with theme options */ @import "tailwindcss" theme(static);inline适用于变量值引用其他 CSS 变量(需在渲染时解析)的场景;static强制所有变量无条件输出,适合需要对外暴露整套令牌(如第三方消费)的情况。
命名空间覆盖
需要从零定制色板时,可先清空默认颜色再定义自己的令牌:
@theme { /* Clear all default colors and define your own */ --color-*: initial; --color-white: #fff; --color-black: #000; --color-primary: oklch(45% 0.2 260); --color-secondary: oklch(65% 0.15 200); /* Clear ALL defaults for a minimal setup */ /* --*: initial; */ }--color-*: initial只清空颜色族,--*: initial则清空全部默认令牌,实现极简最小化主题。
用color-mix()生成半透明色阶
无需逐一手工调色,直接基于主色按比例混合透明色生成 50/100/200 色阶:
@theme { /* Use color-mix() for alpha variants */ --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 ); }容器查询令牌
v4 支持通过--container-*令牌自定义容器查询断点:
@theme { --container-xs: 20rem; --container-sm: 24rem; --container-md: 28rem; --container-lg: 32rem; }v3 到 v4 迁移清单
技能文档给出的迁移清单可逐项勾选执行:
- 用 CSS 的
@theme块替换tailwind.config.ts - 将
@tailwind base/components/utilities改为@import "tailwindcss" - 把颜色定义迁移到
@theme { --color-*: value } - 用
@custom-variant dark替换darkMode: "class" - 把
@keyframes移入@theme块(保证关键帧随主题输出) - 用原生 CSS 动画替换
require("tailwindcss-animate") - 把
h-10 w-10更新为size-10(新简写工具类) - 移除
forwardRef(React 19 已将 ref 作为 prop 传递) - 考虑改用 OKLCH 颜色以获得更好的感知一致性
- 用
@utility指令替换自定义插件
最佳实践
Do's
- 使用
@theme块—— CSS-first 配置是 v4 的核心范式; - 使用 OKLCH 颜色—— 比 HSL 具有更好的感知均匀性;
- 用 CVA 组合变体—— 类型安全的变体定义;
- 使用语义令牌—— 写
bg-primary而不是bg-blue-500; - 使用
size-*—— 新的w-* h-*简写; - 补充无障碍—— ARIA 属性与焦点态不可省略。
Don'ts
- 不要使用
tailwind.config.ts—— 改用 CSS 的@theme; - 不要使用
@tailwind指令—— 改用@import "tailwindcss"; - 不要使用
forwardRef—— React 19 中 ref 直接作为 prop; - 不要滥用任意值—— 优先扩展
@theme; - 不要硬编码颜色—— 一律使用语义令牌;
- 不要忘记暗黑模式—— 两种主题都要测试。
仓库落地:v4 在固定视口渲染场景的护栏
OpenMontage 仓库中的 HyperFrames Tailwind 参考 给出了 v4 在视频合成渲染场景下的硬性规则,与本文的设计系统技能互为补充。核心约束如下:
- 版本契约:HyperFrames
init --tailwind固定使用@tailwindcss/browser@4.2.4浏览器运行时,保持确定性渲染,不替换为 unpinned 的 CDN; - CSS-first 写法:在
<style type="text/tailwindcss">内使用@theme与@utility定义令牌与自定义工具,禁止 v3 的@tailwind base/components/utilities写法,也不要仅为配色/字体新增tailwind.config.js; - v3 迁移路径:若从 v3 迁移,需显式用
@config "./tailwind.config.js";加载旧配置,v4 不会自动探测 v3 配置文件; - 动态类名安全:浏览器运行时只扫描它能看到的类名,切勿在 seek 时动态拼装
bg-${color}-500这类类名,应把完整类令牌静态写进 HTML 或data-*变体; - 渲染安全护栏:固定视口下禁用
md:/lg:断点;关键动画交给 GSAP 等可 seek 的适配器,不用transition-*;hover:/focus:等交互变体在渲染期不会触发;v4 中裸border默认为currentColor(v3 是gray-200),必须显式写颜色;留意 v4 工具类改名(shadow-sm→shadow-xs、rounded-sm→rounded-xs、outline-none→outline-hidden、flex-shrink-*→shrink-*、flex-grow-*→grow-*)。
这些规则与本文的 CVA 组件模式并不冲突:组件库负责常规交互型 Web 界面,而渲染场景应把布局与视觉交给静态类 +@theme令牌,把时间轴关键帧交给 GSAP—— 这正是 OpenMontage 将tailwind-design-system与hyperframes-*系列技能组合使用时遵循的分工边界。
小结
从@theme设计令牌到 CVA 变体组件,从@starting-style原生动画到@utility自定义工具,Tailwind v4 把整套设计系统能力收敛到了纯 CSS 层。本文完整覆盖了 tailwind-design-system 技能文档 的全部模式、迁移清单与最佳实践,并结合 HyperFrames Tailwind 参考 给出了固定视口渲染场景下的落地护栏。按照迁移清单逐项改造,即可让存量 v3 项目平滑进入 CSS-first 时代,并在 Web 组件与视频合成两类场景中复用同一套语义令牌。
【免费下载链接】OpenMontageWorld's first open-source, agentic video production system. 12 production pipelines, 100+ tools, 700+ agent skill and production-knowledge files. Turn your AI coding assistant into a full video production studio.项目地址: https://gitcode.com/GitHub_Trending/op/OpenMontage
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考