如果你刚从传统 CSS、Sass 或 CSS Modules 走过来,第一次看到 Tailwind 的组件,大概会有一个很诚实的反应:一个标签里塞十几个 class,这真的比写 CSS 更好吗?
先别急着说服自己。类名太多确实会带来阅读压力,复制出来的一长串 class 也确实可能失控。Tailwind 没有把复杂度变没,它只是把复杂度从“另一个样式文件里的选择器”搬到了组件结构旁边。这样做值不值,取决于这些 class 能不能被读懂、能不能保持一致,以及团队有没有整理它们的习惯。
这节不做类名词典。我们只做一张真实的“资料更新通知卡”:它要在手机和桌面上都好看,要有清楚的悬停、键盘焦点和禁用反馈,要能根据父元素或相邻控件的状态变化,还要支持暗色模式。做完这张卡,你会知道一串类名应该怎样拆读,也会知道什么时候不该继续往标签上堆类名。
上一节已经把 Tailwind CSS v4 安装进项目,这一节只讨论如何使用现成工具类。下一节会处理主题变量和项目级配置,后面的章节再把这些基础扩展到页面布局、大型组件和高级变体。这里先把“读得懂、改得准”练扎实。
我们先写最小可用版本。通知卡里有一个图标、一段资料信息和一个操作按钮,先不考虑状态、响应式和暗色模式。
<article className="mx-auto flex max-w-2xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-md">
<div className="grid size-12 shrink-0 place-items-center rounded-xl bg-sky-100 font-bold text-sky-700">
新
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-sky-700">课程资料已更新</p>
<h2 className="mt-1 truncate text-lg font-semibold text-slate-950">
响应式布局检查清单
</
两种写法都没有错。传统 CSS 把视觉规则藏进几个语义类名里,HTML 看起来短;代价是查看或调整卡片时,视线要在组件和样式表之间切换,还要确认这些选择器是否被别处复用。Tailwind 把具体规则摆在元素旁边,圆角、间距和颜色一眼可见;代价则是标签明显变长。
关键不在于哪边字符更少,而在于修改的影响范围。把 rounded-2xl 改成 rounded-lg,只会改变眼前这个元素。调整一个被二十处复用的 .resource-notice,影响面就更大。反过来,如果二十处按钮本来就应该永远一致,把公共组件封装起来会比复制二十串工具类更稳。Tailwind 并不反对组件,它反对的是为了隐藏几条样式,就急着创造一个含义模糊的新类名。
看外层 article 的类名时,可以按下面的顺序读:
mx-auto flex items-center 决定它怎样参与布局,以及内部元素怎样排列。max-w-2xl gap-4 p-5 决定尺寸和留白。rounded-2xl border border-slate-200 bg-white shadow-md 决定表面视觉。这是一种阅读顺序,不是 Tailwind 强制的书写顺序。团队完全可以约定“布局 → 尺寸与间距 → 排版 → 颜色与装饰 → 状态 → 响应式 → 暗色”,然后用格式化工具保持一致。只要顺序稳定,长 class 会从一团字符变成几组可扫描的信息。
不要依赖 class 属性中的先后顺序解决冲突。flex grid 并不表示后写的 grid 必然获胜,最终结果取决于生成样式表中的规则顺序。最稳的办法是不要让同一个状态下的两个工具类争夺同一属性;需要条件切换时,只输出真正应该生效的那一个完整类名。
多数工具类可以先按这个形状理解:
变体:变体:工具类/修饰值例如:
dark:md:hover:bg-sky-700/90从右往左认零件会更容易:bg-sky-700 是背景色工具类,/90 把颜色不透明度调到 90%,hover: 限定鼠标悬停,md: 限定中等断点及以上,dark: 再限定暗色环境。只有这些条件同时满足,背景色才变化。
在 Tailwind CSS v4 中,堆叠变体按从左到右的顺序应用。这条规则在普通媒体条件和伪类组合里通常不容易被察觉,因为“暗色且中屏且悬停”无论从哪个条件开始读,交集都一样。但遇到 *、group-* 这类会改变选择器结构的变体时,顺序可能真的改变目标元素。初学阶段建议沿用接近 CSS 嵌套的读法,并让最靠近工具类的前缀描述最直接的状态,例如 dark:md:hover:bg-sky-700。
如果一串前缀仍然让你眼花,可以先把它想成三块积木:条件限定“什么时候生效”,属性说明“改哪里”,取值决定“改成什么”。下面这张图画的就是这条拆读路径。

Tailwind 的类名不是自然语言,也不是组件语义,而是对 CSS 声明的短写。常见线索有:
数字不应该被理解成“设计师随手写的第 5 个像素值”。像 p-5、gap-4 这样的数字会从项目的间距系统取值;默认设置下,常用间距由统一基准推导,因此 p-4 是 1rem。如果下一节修改了主题变量,同一个工具类也可能映射到项目自己的设计刻度。约束的价值就在这里:大家先从同一组间距、字号、颜色和阴影里选,而不是每次都发明一个新数值。
斜杠常用来补充主工具类:
<p className="text-sm/6 text-slate-600">
新增移动端断点检查与键盘焦点说明。
</p>
<span className="bg-sky-600/10 text-sky-700">
已更新
</span>text-sm/6 同时选定小号字体和对应的行高,适合需要稳定行距的说明文字。bg-sky-600/10 使用同一种天蓝色,但背景只保留较低的不透明度。它们都比另写一个孤立值更容易看出与主题色、排版刻度的关系。
当设计稿真的要求一个不在主题刻度里的值,可以用方括号:
<article className="max-w-[42rem] [text-wrap:pretty]">
{/* ... */}
</article>max-w-[42rem] 是任意值,仍然围绕已有的 max-w- 工具工作。[text-wrap:pretty] 是任意属性,直接表达一条 Tailwind 没有现成命名工具类的 CSS 声明。任意写法也可以套变体,例如 md:[--notice-gap:1.5rem]。
它们适合一次性的精确约束、第三方组件要求或临时 CSS 变量,不适合到处重复。如果页面里出现五次 max-w-[42rem],这已经不是“例外”,而是在提醒你:这个值应该进入主题或被封装。主题配置留到下一节,这里只记住判断标准——先找主题工具类,确实没有再用方括号。
现在用实验台把这些命名规律变成手感。先保持默认设置,依次切换间距、尺寸、排版和颜色,观察每次变化时完整 class 与 CSS 声明怎样同步更新;然后故意选一个更大的间距,判断变化发生在容器内部还是元素之间。
操作完再回头读 class,你应该能先说出它负责的属性,再预测大致效果,而不是依赖右侧预览猜答案。特别留意:数字相同不等于物理尺寸必然相同,前缀所属的工具类别和项目主题刻度共同决定最终声明。
现在回到通知卡。我们不一次性吞下整串 class,而是像调试普通 CSS 一样,一次只解决一类问题。
最初的语义结构没有样式:
<article>
<div>新</div>
<div>
<p>课程资料已更新</p>
<h2>响应式布局检查清单</h2>
<p>新增移动端断点检查与键盘焦点说明。</p>
</div>
<button>查看资料</button>
</article>先只处理排列关系:
<article className="flex items-center">
<div className="grid place-items-center">新</div>
<div className="flex-1">...</div>
<button>查看资料</button>
</article>flex 让三个直接子元素横向排列,items-center 让它们在交叉轴居中。正文区域加 flex-1,会占据图标和按钮之外的剩余空间。图标内部使用 grid place-items-center,一个工具类打开网格,另一个让“新”在两个方向居中。
你可能会问:图标居中也能写成 flex items-center justify-center,为什么这里用 Grid?两者都可以。只有一个子元素需要二维居中时,grid place-items-center 更短;如果里面还要继续排列多个项目,Flex 往往更自然。Tailwind 不替你做布局判断,它只让选择直接显示在标记里。
<article className="flex items-center gap-4 p-5">
<div>新</div>
<div className="flex-1">...</div>
<button className="px-4 py-2">查看资料</button>
</article>p-5 控制卡片内容到四周边缘的距离,gap-4 控制三个直接子元素之间的距离。按钮的 px-4 py-2 把水平和垂直内边距分开设置,让点击区域比文字本身更大。
正文内部有另一套更小的节奏:
<div className="flex-1">
<p>课程资料已更新</p>
<h2 className="mt-1">响应式布局检查清单</h2>
<p className="mt-1">新增移动端断点检查与键盘焦点说明。</p>
</div>这里用 mt-1 明确“后一个元素与前一个元素隔开一点”。如果内部是一串同级条目,也可以在父元素上使用 space-y-*;但 gap-* 通常更适合 Flex 或 Grid,并且不会把“最后一个元素不该有下外边距”的问题留给你。
把 p-*、gap-* 和 mt-* 放在同一张卡上对照,会更容易看出它们各自管哪一段空白。下图用三种密度展示统一刻度怎样让组件逐步变松,而不会冒出一批互不相关的像素值。

<article className="mx-auto flex w-full max-w-2xl items-center gap-4 p-5">
<div className="grid size-12 shrink-0 place-items-center">新</div>
<div className="min-w-0 flex-1">
<h2 className="truncate">响应式布局检查清单</h2>
</div>
<button className="shrink-0 px-4 py-2">查看资料</button>
</article>w-full max-w-2xl 是很实用的一对:空间不足时卡片占满可用宽度,空间充足时不再无限拉长;mx-auto 把有最大宽度的卡片居中。size-12 同时设置图标宽高,shrink-0 防止图标和按钮被 Flex 挤瘦。
最容易漏掉的是正文上的 min-w-0。Flex 子项默认可能不愿意缩到内容的最小宽度以下,长标题就会把卡片撑开,truncate 也可能像失灵一样。允许正文区域收缩以后,truncate 才能在空间不足时显示省略号。这类 class 不够“视觉化”,却常常决定组件在真实数据下会不会坏。
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-sky-700">课程资料已更新</p>
<h2 className="mt-1 truncate text-lg font-semibold text-slate-950">
响应式布局检查清单
</h2>
<p className="mt-1 text-sm/6 text-slate-600">
新增移动端断点检查与键盘焦点说明。
</p>
</div>这一块只用了 text-xs、text-sm 和 text-lg 三档。元信息小而有颜色,标题靠字号和字重成为主角,说明文字用稳定行高保证可读。text-* 这个命名空间既能表示字号,也能表示文字颜色,所以要连着后半段读:text-lg 是字号,text-slate-600 是颜色。
别为了“有设计感”给每行文字都安排不同字号。好的层级通常只需要少数刻度,再配合字重、颜色和间距。数值越多,后续越难判断哪一档才是标准。
最后补表面视觉:
<article className="mx-auto flex w-full max-w-2xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-md">
<div className="grid size-12 shrink-0 place-items-center rounded-xl bg-sky-100 font-bold text-sky-700">
新
</div>
{/* 正文与按钮 */}
</article>bg-white 让卡片从浅色页面背景中分离出来,border border-slate-200 给边缘一条轻线,rounded-2xl 统一四角,shadow-md 再加一层不夸张的高度感。边框和阴影同时出现并不是固定配方:如果页面本身已经有明显分区,可能只要边框;如果卡片浮在复杂背景上,阴影才更有用。
到这里,我们没有背一张“常用类名大全”,却已经覆盖了显示、间距、尺寸、排版、颜色、边框和阴影。学习工具类最有效的方式不是按文档目录记忆,而是从一个组件的可见问题出发:哪里没排对、哪里太挤、哪里层级不清,再去找对应工具。
静态卡片只能被看见,还不能让用户确信“哪里能点、当前焦点在哪、为什么按钮不能用”。状态变体就是把某个工具类限定在特定条件下生效。
<button
type="button"
className="shrink-0 rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white
transition-colors hover:bg-sky-700
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600
active:bg-sky-800
disabled:cursor-not-allowed disabled:bg-slate-300 disabled:text-slate-500"
>
查看资料
</button>hover: 是鼠标悬停的增强反馈,但触屏设备未必有真正的悬停,所以不能把关键信息只放在 hover 里。focus-visible: 给键盘导航产生的可见焦点加轮廓。它通常比无条件 focus: 更贴近用户需要,也不要先删默认轮廓却不给替代样式。active: 表示正在按下的短暂状态,颜色再深一档即可,不需要夸张位移。disabled: 对应元素真实的 disabled 属性。只把颜色变灰不够,按钮本身也必须不可提交。这些状态不是五种彼此无关的按钮皮肤,而是同一个操作在不同输入条件下给出的连续反馈。下图把它们排在一起,你可以直接比较颜色、轮廓和可操作感的差异。

禁用状态应该来自业务状态,而不是手工换一串 class:
<button
type="button"
disabled={isLoading}
className="rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white
hover:bg-sky-700 focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-sky-600 disabled:cursor-not-allowed
disabled:bg-slate-300 disabled:text-slate-500"
>
{isLoading ? "正在打开…" : "查看资料"}
</button>这样视觉、交互和可访问语义共享同一个状态。以后排查问题时,也不用猜“看起来灰了”究竟是不是已经禁用。
如果整张卡都可以点击,悬停卡片时,图标、标题和箭头可以一起变化。传统 CSS 会写 .card:hover .title 一类后代选择器;Tailwind 用 group 标记父元素,再在子元素上使用 group-hover:*。
<a
href="/materials/responsive-checklist"
className="group flex items-center gap-4 rounded-2xl border border-slate-200 bg-white p-5
transition hover:border-sky-300 hover:shadow-md
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600"
>
<span className="grid size-12 shrink-0 place-items-center rounded-xl bg-sky-100 text-sky-700 transition-transform group-hover:scale-105">
新
</span>
<span className="min-w-0 flex-1">
<span className="block text-lg font-semibold text-slate-950 group-hover:text-sky-700">
响应式布局检查清单
group 本身不产生视觉效果,它只是给后代变体一个可识别的父状态。这里仍然给链接保留了 focus-visible:,因为 group hover 不是键盘焦点的替代品。如果页面存在嵌套 group,可以为它们命名;但普通卡片先用无名 group 就够了,别为了展示语法把选择器写复杂。
现在给卡片加一个“稍后学习”复选框。勾选后,标签文字和提示都变化,不写 JavaScript:
<div className="mt-4 border-t border-slate-200 pt-4">
<input id="save-later" type="checkbox" className="peer sr-only" />
<label
htmlFor="save-later"
className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-slate-300 px-3 py-2
text-sm font-medium text-slate-700
peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-sky-600
peer-checked:border-sky-600 peer-checked:bg-sky-50 peer-checked:text-sky-700"
>
稍后学习
</label>
<
peer 标在真正拥有状态的输入框上,后面的标签和段落通过 peer-checked:* 读取它的选中状态。这里有一个硬限制:由于底层使用后续兄弟选择关系,被影响的元素必须出现在 peer 后面。把提示段落放到输入框前面,再多写几个 peer-checked: 也不会生效。
sr-only 把原生复选框从视觉上隐藏但保留给辅助技术,标签通过 htmlFor 与它连接。焦点落在隐藏输入框时,peer-focus-visible: 把轮廓显示在可见标签上。这样既有定制外观,也没有把键盘操作和语义一起藏掉。
把两种传播关系并排看,会更容易记住作用方向:group 从父元素往后代传递状态,peer 从前面的同级元素影响后面的同级元素。

下面的实验室把按钮自身状态、父子联动和兄弟联动放进同一个可操作页面。先只用键盘的 Tab 和空格键完成一次操作,再换鼠标;观察焦点轮廓落在哪里,以及卡片悬停和复选框选中分别影响了哪些元素。
如果某个变化与你预期不同,就从状态拥有者开始检查:是元素自己拥有 hover、父元素带着 group,还是前置控件带着 peer。确定状态从哪里来,再检查目标元素上的变体前缀,通常比盯着整串 class 更快。
Tailwind 的默认断点是最小宽度条件:无前缀工具类作用于所有尺寸,sm: 从 40rem 起生效,md: 从 48rem 起生效,lg: 从 64rem 起生效,后面还有更大的 xl: 与 2xl:。因此,sm: 的意思不是“只在小手机上”,而是“达到 sm 断点以后”。
所以更准确的画面不是“为手机、平板、桌面分别造三张页面”,而是先让默认布局独立成立,再随着空间增加一层层覆盖。下图中的积木就是这种渐进增强关系。

这也是移动优先最常见的误区:
{/* 错误理解:sm 并不负责比 40rem 更窄的屏幕 */}
<h2 className="sm:text-center">响应式布局检查清单</h2>
{/* 正确思路:默认覆盖窄屏,断点前缀逐步覆盖宽屏 */}
<h2 className="text-center sm:text-left">响应式布局检查清单</h2>手机空间有限,按钮放到整张卡底部,点击区域占满宽度:
<article className="flex w-full flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-md">
<div className="flex min-w-0 items-center gap-3">
<div className="grid size-11 shrink-0 place-items-center rounded-xl bg-sky-100 text-sky-700">新</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-sky-700">课程资料已更新</p>
<h2 className="mt-1 truncate text-base font-semibold text-slate-950">
这个默认版本不用任何断点前缀,所以在最窄的环境里也成立。按钮不会和长标题抢一行,卡片边距较紧,标题字号也没有过度放大。
<article className="flex w-full flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-md
sm:p-5 md:max-w-2xl md:flex-row md:items-center md:gap-5">
<div className="flex min-w-0 items-center gap-3 md:flex-1">
<div className="grid size-11 shrink-0 place-items-center rounded-xl bg-sky-100 text-sky-700 md:size-12">
新
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-sky-700">课程资料已更新</p>
<
变化可以直接按断点读出来:
flex-col,到 md: 才变成 flex-row。p-4,从 sm: 起增加为 p-5。hidden,从 md: 起恢复 block。w-full,从 md: 起改成 w-auto。注意,断点应该由内容开始拥挤的位置决定,不要把 sm 简单翻译成某台手机、把 md 翻译成某款平板。你可以在浏览器里慢慢缩窄组件:标题开始被过早截断、按钮开始挤压正文的那个范围,才是需要重排的证据。
接下来不要只拖动整个浏览器窗口。先在模拟器里把视口停在断点前后各一点的位置,记录卡片在哪个瞬间改变列数、间距和按钮宽度,再尝试判断是哪一条带前缀的工具类开始生效。
观察完成后,把视口重新收回最窄宽度,确认页面仍然可用。这个逆向检查很重要:宽屏增强可以增加信息或改变排列,却不应该成为窄屏版本能够工作的前提。
响应式不是给同一个元素连续叠五套尺寸。很多组件只需要一个默认状态和一个关键断点。前缀越多,组合越难验证;只有界面真的在某个宽度出现问题时,再增加新的覆盖。
dark: 和 hover:、md: 一样,也是条件变体。默认情况下,它跟随系统的颜色偏好;如果产品提供手动切换,需要在 CSS 中把 dark 变体改成由某个祖先选择器触发。
先为通知卡补齐暗色表面:
<article className="rounded-2xl border border-slate-200 bg-white p-5 shadow-md
dark:border-white/10 dark:bg-slate-900 dark:shadow-none">
<div className="rounded-xl bg-sky-100 text-sky-700
dark:bg-sky-400/10 dark:text-sky-300">
新
</div>
<p className="text-sky-700 dark:text-sky-300">课程资料已更新</p>
<h2 className="text-slate-950 dark:text-white">响应式布局检查清单</h2>
<p className="text-slate-600 dark:text-slate-400"
这里不是把 white 机械替换成 black。暗色背景使用 slate-900 保留一点色相,主标题用白色,说明文字降到 slate-400,边框则用半透明白色。亮色下的阴影在深色背景上不一定清楚,所以暗色状态直接 shadow-none,用边框完成分层。
下图保留完全相同的信息结构,只改变背景、文字、边界和强调色之间的关系。对照左右两边,你会发现暗色设计的关键是重新建立层级,不是做一次颜色反转。

一个容易漏掉的检查是交互状态。亮色按钮的 hover:bg-sky-700 在暗色环境里可能过沉,可以继续叠加:
<button className="bg-sky-600 text-white hover:bg-sky-700
dark:bg-sky-500 dark:hover:bg-sky-400 dark:hover:text-slate-950">
查看资料
</button>dark:hover:bg-sky-400 表示暗色条件中的悬停状态。默认色、hover、focus、disabled 都应该分别看一次,而不是静态截图看着舒服就算完成。
如果只需要尊重操作系统设置,使用默认 dark: 行为即可。如果页面上有“浅色 / 深色 / 跟随系统”开关,可以在主样式文件中把 dark 变体改为由 .dark 祖先触发:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));然后由应用逻辑维护根元素:
const root = document.documentElement;
function applyTheme(theme: "light" | "dark" | "system") {
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
root.classList.toggle("dark", theme === "dark" || (theme === "system" && systemDark));
}完整产品还要保存用户选择,并尽量在页面首次绘制前应用主题,避免先亮后暗的闪烁。这里先建立清楚的分工:dark:* 负责“暗色时长什么样”,根元素上的 .dark 负责“现在是不是暗色”。主题变量和更完整的配置方式会在下一节继续处理。
下面的工作台提供亮色、暗色和跟随系统三种模式。先在亮色模式记录卡片边界、正文对比度和按钮状态,再切到暗色模式逐项核对;最后选择跟随系统,看看触发条件改变时,组件本身的工具类是否仍然保持不变。
切换时不要只看页面背景。继续用鼠标悬停按钮、用 Tab 移入焦点,并检查原生控件;如果某个状态在暗色中突然消失,缺的通常不是主题开关逻辑,而是该状态对应的 dark:* 视觉规则。
Tailwind 的类名会多,这个事实不需要粉饰。真正需要避免的是三种混乱:同一属性互相打架、重复组件到处复制、运行时拼出构建工具看不见的类名。
下面这段代码很难靠肉眼判断:
<div className="flex grid bg-white bg-slate-900 p-4 p-6">
{/* 到底想要哪一种? */}
</div>不要通过交换 class 位置碰碰运气,直接让条件只返回一套完整结果:
const layoutClass = compact ? "grid p-4" : "flex p-6";
<div className={`${layoutClass} bg-white`}>
{/* ... */}
</div>如果是第三方样式或内联样式造成的明确优先级问题,v4 可以把 ! 放到工具类末尾,例如 bg-white!。但这应该是理清冲突路径后的最后手段,不是每次冲突就加的止痛片。一个组件里连续出现多个 !,通常说明样式所有权没有划清。
如果同一张通知卡出现多次,提取 React 组件,让结构和整串工具类只维护一份:
type ResourceNoticeProps = {
title: string;
description: string;
disabled?: boolean;
};
export function ResourceNotice({
title,
description,
disabled = false,
}: ResourceNoticeProps) {
return (
<article className="flex w-full flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-md dark:border-white/10 dark:bg-slate-900 dark:shadow-none md:flex-row md:items-center md:p-5"
调用处只传内容和状态,视觉规则仍然与组件结构放在一起。等到多个不同组件都重复同一套颜色、间距或圆角,再考虑在下一节把它们沉淀成主题 token。不要因为一个标签有十五个 class,就立刻把它们整体塞进 .card;那只是在标签旁边重新建了一个需要来回跳转的抽屉。
实用的组织办法有三条:
可读性的目标不是让每个标签都变短,而是让改动位置明确。一个长但分组稳定、没有冲突的 className,往往比五个名字宽泛的自定义类更容易维护;一个塞满条件表达式、重复值和强制优先级的 className,则应该尽快整理。
Tailwind 是常用界面样式的工作台,不是禁止普通 CSS 的规则。下面几类问题继续硬堆 class,往往会让代码更难读:
还有一个更现实的条件:团队是否愿意共同维护这种写法。如果每次评审都要先争论“class 太丑”,而且没人遵守分组、封装和主题刻度,再好的工具也会变成摩擦源。可以先挑一个边界清楚的小组件试用,约定排序和抽取规则,再根据真实修改成本决定是否扩大范围。
反过来,也不要因为出现一条任意值就宣布 Tailwind 失败。判断标准仍然是可读性和复用方式:一条局部例外放在元素旁边很清楚,就留下;相同例外开始重复,就收进主题;选择器或动画逻辑本身已经比组件结构复杂,就写普通 CSS。工具类和自定义 CSS 可以同时存在,没必要选边站。
Tailwind 会把源文件当作普通文本扫描,找到看起来像完整工具类的字符串,再为真正识别到的类生成 CSS。它不会运行你的 JavaScript,也不会理解字符串拼接最后会得到什么。
这段代码在浏览器里可以拼出 bg-sky-600,构建阶段却只看见 bg-、变量和 -600:
function NoticeButton({ color }: { color: "sky" | "rose" }) {
return (
<button className={`bg-${color}-600 hover:bg-${color}-700 text-white`}>
查看资料
</button>
);
}结果通常是:开发时某次似乎能显示,换环境、清缓存或正式构建后颜色消失。问题不在 React 的模板字符串,而在于源码里从未出现完整的 bg-sky-600 或 bg-rose-600。
安全写法是把有限状态映射到完整类名:
const colorVariants = {
sky: "bg-sky-600 hover:bg-sky-700 focus-visible:outline-sky-600",
rose: "bg-rose-600 hover:bg-rose-700 focus-visible:outline-rose-600",
};
function NoticeButton({ color }: { color: keyof typeof colorVariants }) {
return (
<button
className={`${colorVariants[color]} rounded-lg px-4 py-2 text-sm font-semibold text-white focus-visible:outline-2 focus-visible:outline-offset-2`}
>
现在每个候选类都以完整字符串存在,扫描器能找到,读代码的人也能看出不同颜色不是简单替换一个色名:黄色按钮可能需要黑字,暗色状态也可能需要另一档颜色。显式映射虽然多几行,却把设计决策也写清楚了。
下图把构建阶段画成一台扫描器:完整字符串能被收进工具箱,运行时才拼起来的碎片却可能从漏斗里掉出去。样式“偶尔消失”的原因,其实发生在浏览器运行代码之前。

如果颜色来自数据库或用户任意输入,预先枚举所有类名不现实。此时可以让动态值走内联 CSS 变量,再用静态工具类读取变量:
<button
style={{ "--notice-color": apiColor } as React.CSSProperties}
className="bg-(--notice-color) rounded-lg px-4 py-2 text-sm font-semibold text-white"
>
查看资料
</button>布局、间距和排版继续使用可检测的静态工具类,真正来自运行时的数据才进入变量。这样比维护一份无限增长的颜色白名单更诚实。
不要把用户输入直接塞进任意属性或 style。动态样式仍然要做格式校验和允许范围限制。Tailwind 解决的是类名生成问题,不会替应用验证不可信数据。
下面是这一节的最终版本。先别数 class 数量,试着按“基础样式 → 交互状态 → 响应式 → 暗色”扫描每个元素:
type ResourceNoticeProps = {
title: string;
description: string;
isLoading?: boolean;
};
export function ResourceNotice({
title,
description,
isLoading = false,
}: ResourceNoticeProps) {
return (
<article
className=
这张卡在窄屏纵向排列,按钮占满宽度;到 md: 后变成横向布局,说明文字出现,按钮恢复内容宽度。鼠标经过卡片时,边框、阴影、图标和标题形成一组协调反馈;键盘焦点进入卡片时,边框也会变化。按钮有独立的 hover、focus、active 和 disabled 状态。暗色环境下,背景、文字、边框、阴影以及各个交互状态都重新检查过。
它仍然有很多 class,但每一组都回答一个具体问题。更重要的是,你现在可以在不翻样式表的情况下完成小改动:卡片太挤就找 gap-* 和 p-*,标题溢出就查 min-w-0 与 truncate,手机布局不对就从无前缀类开始,暗色悬停不清楚就找 dark:hover:*。
把整节的改造过程摊开来看,就是从骨架出发,依次补上间距、配色、交互与适配。下图刻意保留每个中间状态,因为实际开发中最可靠的排查方式也正是一次只验证一层。

这就是“读懂工具类”真正带来的速度。不是敲 class 更快,而是从界面问题定位到样式决定的路径更短。
给最终通知卡增加“重要”状态,要求如下:
bg-${color}-600 这样的动态类名。md: 以上才显示“请在本周完成”。做完后,再用四种方式检查卡片:把窗口缩到手机宽度、用 Tab 键移动焦点、切换暗色模式、把按钮设为禁用。只盯着正常桌面截图,很容易漏掉真正会在用户手里暴露的问题。
下一节会把反复出现的颜色、间距和断点从“默认刻度”变成项目自己的主题。再往后构建导航、表单和复杂页面时,你会继续使用同一套读法,只是元素更多、组合范围更大;高级章节里的自定义变体,也仍然遵循“条件前缀 + 工具类”这个基本结构。