如果你刚从传统 CSS 过来,看到下面这行代码,很可能会先皱一下眉:
<button className="rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-sky-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 disabled:cursor-not-allowed disabled:opacity-50">
继续学习
</button>圆角、颜色、间距、字重、阴影、悬浮、键盘焦点、禁用状态,全挤在一个 className 里。说它像一碗“class 汤”并不过分。问题是,我们真正讨厌的究竟是类名多,还是样式没有边界、改动会互相波及、组件在不同页面越长越不一样?
Tailwind 的选择很直接:先把样式如实放到元素旁边,让你一眼看见按钮为什么长成这样;等某一组标记真的需要反复出现,再把整个按钮提取成组件。这样组织代码的单位不是 .blue-button 之类需要猜测的类名,而是页面结构、数据和组件职责。
这一章不做工具类背诵。我们会从一张空白页面开始,持续搭同一个“课程工作台”:顶部有导航,中间有课程卡片网格,右侧有学习进度和计划表单,最后还有一个确认操作的对话框。每加一层样式,都回答三个问题:这个元素负责什么、为什么选这个布局方式、在空间不足或状态变化时会怎样。
完成后,你应该能独立判断:一行内容什么时候用 Flexbox,一片内容什么时候用 Grid,页面断点和容器查询分别该放在哪里,以及一长串工具类什么时候应该留在原地、什么时候应该变成组件。
本章示例按 Tailwind CSS v4 编写。默认间距、颜色和断点都来自主题变量,所以 gap-4、text-slate-600、md:grid-cols-2 不是随手编的魔法数字,而是同一套设计约束在不同位置的调用。
打开设计稿时,最容易犯的错误是立刻挑背景色、圆角和阴影。这样做很有成就感,却可能在十分钟后发现:侧栏根本放不下,卡片标题一长就挤掉按钮,移动端还得全部推翻。
我们先把工作台拆成稳定的区域:
课程工作台
├── 顶部导航
│ ├── 品牌与当前课程
│ └── 搜索、通知、用户操作
└── 主内容容器
├── 页面标题与主要操作
└── 响应式内容区
├── 主栏:课程卡片网格
└── 侧栏:进度、学习计划、危险操作这棵树已经暗示了布局选择。顶部导航是一条主轴明确的横向队列,适合 Flexbox。主栏和侧栏是二维轨道,适合 Grid。课程卡片内部可能随自身宽度改变结构,所以后面会用容器查询,而不是继续往页面上堆视口断点。
先给页面一个只控制宽度和留白的壳层:
export function WorkspaceShell({ header, children }) {
return (
<div className="min-h-dvh bg-slate-50 text-slate-950">
{header}
<main className="container mx-auto px-4 py-6 sm:px-6 lg:px-8 lg:py-10">
{children}
</main>
</div>
)
}运行后,内容在窄屏占满可用宽度,两边保留 px-4;屏幕变宽后,水平内边距逐步增加。container 会让最大宽度跟随当前断点,但它不会自己居中,也不会自带左右内边距,因此 mx-auto 和 px-* 仍然有明确职责。
这里也可以不用 container,改成 mx-auto w-full max-w-7xl。两种写法没有高下之分:希望宽度在默认断点处一级级变化,用 container;希望页面始终流动,只在某个上限停止,用固定的 max-w-* 通常更直观。工作台内容较多,我们先使用 container,让大屏能容纳完整侧栏。
container 和 @container 不是一回事。前者是页面最大宽度工具类,后者把元素标记成容器查询的参照物。它们名字相近,解决的问题却不同。
在继续搭导航前,我们先把最刺眼的问题解决掉。假设课程工作台需要一张“下一节课程”卡片,传统 CSS 很可能这样写:
<article className="lesson-card">
<div className="lesson-card__meta">下一节 · 18 分钟</div>
<h2 className="lesson-card__title">用 Grid 搭课程列表</h2>
<button className="lesson-card__action">开始学习</button>
</article>.lesson-card {
padding: 1.25rem;
border: 1px solid #e2e8f0;
border-radius: 1rem;
background: white;
box-shadow: 0 1px 2px rgb(15 23 42 / 0.05);
}
.lesson-card__meta { color
标记确实短,但“这张卡片为什么是这个间距”“标题和元信息差几级”“改圆角会不会影响其他 .lesson-card”都要跳到另一份文件确认。类名只表达了角色,没有表达结果。
Tailwind 版本把结果放回元素旁边:
<article className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<p className="text-xs font-semibold text-sky-700">下一节 · 18 分钟</p>
<h2 className="mt-2 text-lg font-semibold text-slate-950">用 Grid 搭课程列表</h2>
<button className="mt-4 rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white hover:bg-sky-700">
开始学习
</button>
</article>现在你不用记住 .lesson-card__title 背后是什么。mt-2 明确说明标题与元信息的距离,text-lg 和 font-semibold 明确说明标题层级,rounded-2xl 明确说明圆角。改动只落在当前元素,不会通过选择器悄悄波及另一张卡。
当然,代价也摆在眼前:标签变长,重复卡片会复制同一串类。如果这张卡只出现一次,长一点并没有实际伤害;如果它成为稳定的课程单元,就提取 LessonCard,让页面只传标题、时长和操作。Tailwind 没有取消组织样式,它只是让我们晚一点抽象,等真正的重复和职责出现后再抽象。
还有一个常被忽略的收益:p-5、mt-2、mt-4 都来自同一间距刻度。团队成员不必各自在 CSS 里发明 17px、19px、22px。这里的“约束”不是让界面千篇一律,而是让同一产品的节奏可预测。需要特殊值时仍能写任意值,但默认先用主题刻度,页面会更容易保持一致。
评价工具类标记时,不要只数 class 的个数。还要看一次改动要跳几个文件、一个选择器影响多少实例、相同视觉是否使用相同设计刻度。代码短不等于维护路径短。
导航的任务不是展示所有功能,而是让品牌、当前位置和高频操作在同一条线上保持关系。它天然是一维问题:左边一组,右边一组,中间的剩余空间由父元素分配。
export function WorkspaceHeader() {
return (
<header className="border-b border-slate-200 bg-white">
<div className="container mx-auto flex h-16 items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
<a href="/learn" className="flex min-w-0 items-center gap-3">
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-sky-600 text-sm font-bold text-white">
学
</span>
<
运行后,左侧品牌会尽量保留,课程名称过长时由 truncate 截断;右侧按钮通过 shrink-0 保住可点击面积。到 md 宽度后,横向导航出现,移动菜单按钮隐藏。
这段代码里最关键的不是 justify-between,而是收缩规则。Flexbox 默认允许项目收缩,文本所在的层级如果没有 min-w-0,长标题可能坚持使用自己的最小内容宽度,把右侧按钮挤出屏幕。反过来,操作按钮不该被压扁,所以给它 shrink-0。
justify-between 不是万能分隔器如果导航右侧以后增加搜索框、通知按钮和头像,可以在右侧再建一个 Flex 组,用 gap-2 控制组内关系。不要把所有元素都作为最外层容器的直接子元素,然后指望 justify-between 自动产生正确层级。它只会分配剩余空间,不理解哪些控件属于同一组。
<div className="flex items-center gap-2">
<button className="rounded-lg p-2 text-slate-500 hover:bg-slate-100" aria-label="查看通知">
通知
</button>
<button className="rounded-full bg-slate-900 px-3 py-2 text-sm font-medium text-white">
小林
</button>
</div>效果上,两个操作之间始终相隔同一距离;外层宽度变化时,它们仍作为一个整体待在导航右侧。这就是 Flexbox 最舒服的场景:一条轴线、内容宽度不完全固定、元素之间有明确的成组关系。
工作台主体需要同时处理“主栏多宽”和“侧栏多宽”。如果用 Flexbox,也能写成 flex-1 加固定宽度,但当侧栏需要跨行、主栏内部还要对齐时,逻辑会越来越绕。Grid 可以直接说清楚轨道。
我们从移动端的单列开始:
export function WorkspaceLayout({ main, sidebar }) {
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
<section className="min-w-0">{main}</section>
<aside className="min-w-0">{sidebar}</aside>
</div>
)
}没有前缀的 grid gap-6 是所有宽度下的基础样式,所以手机上主栏和侧栏自然上下排列。到 lg 后,模板变成两列:第一列 minmax(0, 1fr) 吃掉剩余空间,第二列固定为 20rem。lg:items-start 避免较短的侧栏被默认拉伸到和主栏等高。
minmax(0, 1fr) 看起来比 1fr 麻烦一点,却是在真实内容里很有用的保险。Grid 项目的默认最小宽度可能受长单词、代码或不可换行内容影响;把最小值明确为 0,主栏才真的允许收缩。外层的 min-w-0 也是同一思路:内容可以在自己的轨道内处理溢出,不要把整个网格撑开。
把轨道线画出来后,这个选择会更直观:六张课程卡共用列宽、间距和底部对齐线,父级网格一次定义了整片区域的规则。

看图时别只数三列两行。试着指出哪些尺寸属于网格父级,哪些留白应该由卡片自己负责,这会直接影响后面的组件边界。
移动优先的实际写法是:无前缀类描述最低条件,断点前缀只覆盖空间变大后真正需要改变的部分。
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">不要反过来先写桌面双栏,再用 max-lg: 拼命撤销列数、宽度和排列。那样你会同时维护“建立桌面布局”和“在小屏拆掉桌面布局”两套思路。这里的单列不是桌面布局的残缺版,而是内容自然流动的默认状态。
真实页面不会在设计稿上标注“这里必须用 Flexbox”。拿不准时,可以按顺序问四个问题。
第一个问题:你是在安排一条轴,还是同时设计行和列?导航、按钮组、头像与姓名通常只有横向或纵向一条主轴,用 Flexbox 最自然。课程卡片列表同时关心列数、列宽和行列间距,用 Grid 更直接。
第二个问题:尺寸主要由内容决定,还是由轨道决定?一组按钮的宽度应跟随文案,Flexbox 很擅长在剩余空间里分配和收缩。工作台主栏和侧栏的宽度由页面结构预先规定,Grid 的轨道声明会比一串 flex-basis 更容易读。
第三个问题:后续是否需要跨列或对齐到共同轨道?如果一张“今日任务”卡片要横跨两列,或者多行数据要共用列线,Grid 可以用 col-span-* 直接表达。Flexbox 能做视觉上相似的排列,但跨行对齐需要额外计算和宽度约定。
第四个问题:元素顺序是否必须与阅读顺序一致?本章移动端让主栏先出现、侧栏后出现,正好沿用 DOM 顺序。若你频繁使用 order-* 把视觉顺序改得和源码不同,要警惕键盘焦点与屏幕阅读顺序仍按 DOM 流动。布局工具能移动盒子,不能自动重写内容语义。
同一个组件里混用两者完全正常。外层课程列表用 Grid 管理卡片轨道,卡片标题行用 Flexbox 让标题与进度徽标分列,按钮内部再用 inline-flex 对齐图标和文字。不要争论谁更强,只看当前这一层关系需要什么。
下面这张图把判断压缩成两个可视问题:先看内容是否沿一条主轴排列,再看是否需要共同的行列轨道。导航和课程卡片刚好是两种典型答案。

试着先不看类名,只根据这些任务的结构做选择。下面的决策台会让你在同一组内容上切换 Flex 和 Grid,并直接查看父容器的 Tailwind 类名。
操作时先用“一行任务”测试对齐和剩余空间,再换成多行课程卡片。观察列数和 gap 如何交给父级统一管理,也看看在只有一条主轴时,Grid 是否真的带来了更清楚的代码。
20rem 是本章工作台在大屏下的产品选择,不是 Tailwind 推荐的通用侧栏宽度。如果侧栏要展示更长表单,可以用 minmax(18rem, 22rem);如果希望它随可用空间变化,可以写 minmax(16rem, 24rem),同时给主栏保留最小可读宽度。
<div className="grid gap-6 xl:grid-cols-[minmax(36rem,1fr)_minmax(18rem,22rem)]">
<main className="min-w-0">课程内容</main>
<aside className="min-w-0">计划与进度</aside>
</div>运行后,只有到 xl 才建立双栏,因为主栏需要至少 36rem 才能舒服地放课程卡片;侧栏则在 18rem 到 22rem 之间变化。断点不应只按“常见设备名”机械选择,而要观察内容什么时候真的有空间完成重排。
侧栏在大屏上是独立轨道,回到窄屏后却应回到自然文档流,必要时再收起为可展开面板。图里的两个版本展示的是同一份内容如何换位,不是两套独立页面。

沿着箭头检查一遍:主内容的阅读顺序有没有改变,进度是否仍然容易找到,收起侧栏后是否还有明确的打开入口。这三项比“始终粘在右边”更能检验侧栏是否真的可用。
现在给主栏加标题和课程列表。页面级网格先按视口决定列数:
export function CourseSection({ courses }) {
return (
<section aria-labelledby="course-heading">
<div className="mb-5 flex items-end justify-between gap-4">
<div>
<p className="text-sm font-medium text-sky-700">本周学习</p>
<h1 id="course-heading" className="mt-1 text-2xl font-bold tracking-tight text-slate-950 sm:text-3xl">
运行后,手机一列,较宽屏幕两列,超宽屏幕三列。这里使用视口断点是合理的,因为它决定的是页面主栏要放几张卡片。
可问题也随之出现:同一个 CourseCard 以后可能出现在全宽搜索页、双栏工作台、窄侧栏推荐区。若卡片内部继续写 sm:flex-row,它响应的是浏览器宽度,不是自己实际拿到的宽度。浏览器已经很宽,但卡片可能仍只有 280px;此时强行横排就会挤成一团。
容器查询专门解决这个问题。先把卡片根元素标成查询容器,再让内部布局根据卡片宽度变化:
export function CourseCard({ course }) {
return (
<article className="@container/card overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="grid gap-4 p-4 @md/card:grid-cols-[auto_minmax(0,1fr)] @md/card:items-center">
<div className="grid size-14 place-items-center rounded-xl bg-sky-100 text-lg font-bold text-sky-800">
{course.shortName}
</div>
<div className="min-w-0">
@container/card 给容器命名为 card,@md/card:* 明确查询这个容器。简单组件可以只写 @container 和 @md:*;命名的好处是在嵌套容器变多时,不会误用最近的那个祖先容器。
进度值来自运行时数据,可能是 37、62 或 91。把它拼成 `w-${course.progress}` 既不对应稳定的间距刻度,也可能无法被 Tailwind 的源码扫描识别。此处真正动态的是数据,不是设计变体,所以直接设置 style={{ width: ... }} 更诚实。
如果动态值要参与多个样式,也可以传 CSS 变量,再用任意值工具类读取:
<div
style={{ "--course-progress": `${course.progress}%` }}
className="h-2 w-(--course-progress) rounded-full bg-sky-600"
/>一个好判断是:视口断点负责页面编排,容器查询负责组件适配。它们可以同时出现,但不要让组件猜测自己会被放在页面的哪一列。
这个区别只看代码容易停在概念上。下面的响应式工作台允许你拖动父容器宽度,同时查看同一张课程卡在正文和窄侧栏中的变化。先保持浏览器窗口不动,只改容器宽度。
观察卡片是在哪个宽度点改变排列,再对照页面断点和容器查询的类名。如果改变浏览器宽度时卡片不该变却变了,通常说明组件仍在偷看视口;只在父容器足够宽时改变,才是我们要的自治组件。
工作台已经有三种不同的空白:页面边缘的内边距、主栏与侧栏之间的间隔、卡片内部元素的距离。如果把它们都写成“看着差不多”的 m-*,以后改布局时就很难知道该动谁。
可以用一套简单分工来避免混乱:
gap 适用于 Flexbox 和 Grid,也能正确处理换行、行列间距和动态子项。space-x-*、space-y-* 本质上是给相邻子项添加外边距,遇到换行、复杂排序或 Grid 时容易和视觉行列对不上。工作台卡片会换列,所以网格必须用 gap-4,不能用 space-x-4。
再看侧栏:
export function SidebarStack({ children }) {
return <div className="space-y-4">{children}</div>
}这里 space-y-4 可以接受,因为子项始终按 DOM 顺序垂直堆叠,不会换行,也不参与二维布局。如果未来侧栏改成可拖拽排序,或其中一些模块会被包进额外层级,换成 flex flex-col gap-4 通常更稳。
间距出问题时,先问“这段空白是谁的责任”,会比盲试 mt-4 或 p-6 更快。下图把页面边界、区域留白、同级间隔和组件内部分成四层,每一层都有自己的负责者。

把图中任意一条空白遮住,再根据它两边的内容关系猜由哪个容器控制。如果一段距离同时由父级 gap-* 和子项 mt-* 承担,就应回到结构上去掉其中一份职责。
工作台不是海报。用户打开页面后,首先要确认自己在哪,其次要看下一步学什么,最后才是次要状态。排版要把这个阅读顺序做出来。
我们已经使用了四个相对稳定的层级:
text-2xl font-bold tracking-tight text-slate-950,承担页面主题。text-base font-semibold text-slate-950,承担内容单元名称。text-sm/6 text-slate-600,保持连续阅读的行高。text-xs font-medium text-slate-500,可查阅但不抢视线。Tailwind 的字号工具通常同时带有配套行高。如果正文需要更明确的节奏,可以使用 text-sm/6 这种“字号/行高”组合。标题行高要紧凑,连续说明要有足够呼吸;不要为了统一而给所有文字都套 leading-relaxed。
接下来给侧栏做进度面板:
export function ProgressPanel({ completed, total }) {
const percentage = Math.round((completed / total) * 100)
return (
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm" aria-labelledby="progress-title">
<div className="flex items-start justify-between gap-4">
<div>
<p
运行后,白色面板从浅灰页面背景中分离出来;细边框定义边界,shadow-sm 只提供很轻的高度提示。三种手段没有一起大喊“看我”:背景负责分区,边框负责边界,阴影负责层级。
旧习惯里常把透明背景拆成 bg-opacity-*。在 v4 中,直接把透明度写到颜色后面更清楚,例如 bg-slate-950/60、border-white/10、text-slate-950/70。如果写 opacity-60,整个元素连同文字和子元素都会一起变淡,这通常不是遮罩或浅色背景真正想要的效果。
阴影越大不代表组件越重要。普通卡片用边框或轻阴影即可;只有浮在页面上方的菜单、对话框等覆盖层,才需要更明显的阴影。每张卡片都用 shadow-xl,页面反而失去层级。
配色容易掩盖结构问题。检查工作台时,可以暂时把彩色部分想象成灰度:如果去掉天蓝和玫红后,你仍能通过字号、字重、间距、边界判断页面标题、卡片标题、说明和操作,层级通常比较稳。如果所有重点都靠颜色区分,深色模式、低饱和屏幕或色觉差异下就可能失效。
以课程卡片为例,category 用小字号和中等字重退到第二层,标题用较大字号和深色进入第一层,进度徽标靠形状与位置成为状态信息。即使把它们都改成灰色,阅读顺序仍然成立。颜色只是补充语义,不负责单独扛起语义。
正文宽度也属于排版。页面简介使用 max-w-2xl,不是为了在大屏上留一块“高级感空白”,而是避免一句话横跨整张显示器。卡片标题可以截断,因为列表页还有进入详情的路径;错误说明不能随便截断,因为用户需要完整知道怎么修正。相同的 truncate 放在不同内容上,结果可能完全不同。
边框和阴影还要考虑页面背景。白色卡片放在 bg-slate-50 上,一条 border-slate-200 已能形成稳定边界;白卡叠白底时,轻阴影才更有必要。先看相邻区域是否已有对比,再决定要不要增加视觉手段,不要把“卡片 = 圆角 + 边框 + 大阴影”当作固定配方。
课程工作台需要快速扫视,所以卡片内部用 p-4 或 p-5,列表间用 gap-4。阅读型页面可能需要更宽的行高和段落间距,数据密集型后台则会收紧行高与垂直留白。Tailwind 给的是刻度,不会替你决定密度。
可以用一个简单测试:在不滚动的情况下,用户是否看得到当前课程、进度和下一步操作?如果为了“宽松”导致关键操作全部落到首屏之外,就该减少装饰性留白;如果每个区域挤得无法辨认归属,就该增加组间间距,而不是只把字体缩小。
表单好不好用,不取决于默认状态有多漂亮,而取决于用户输入、键盘导航、校验失败和等待提交时能不能判断当前发生了什么。
先做一个完整字段:
<div>
<label htmlFor="weekly-goal" className="block text-sm font-medium text-slate-800">
每周学习目标
</label>
<p id="weekly-goal-help" className="mt-1 text-sm/6 text-slate-500">
建议先从 3 到 5 节开始,完成后再增加。
</p>
<input
id="weekly-goal"
name="weeklyGoal"
type
单看两段静态标记,很容易漏掉状态之间的连续性。下面的表单状态台会让你真正用鼠标、键盘和校验按钮走一遍 focus、focus-visible、aria-invalid、disabled 和 peer-checked。
先用 Tab 键从头到尾走一遍,记下哪个控件正在获得焦点;再制造一次错误并触发校验,检查错误文字、输入框属性和焦点去向是否同步。最后切到禁用状态,确认它不是只“变灰”,而是真的不能继续操作。
现在把字段放进侧栏面板:
export function StudyPlanForm({ isSaving, error }) {
return (
<form className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 className="text-lg font-semibold text-slate-950">调整学习计划</h2>
<p className="mt-1 text-sm/6 text-slate-600">计划只影响提醒频率,不会改变课程内容。</p>
<div className="mt-5 grid gap-4">
<div
这里把状态判断留给 React,把状态外观交给 Tailwind。isSaving 同时改变按钮文案和原生 disabled 属性,视觉类再响应这个属性。错误信息也由真实校验结果决定,不必让 CSS 猜业务规则。
focus 和 focus-visible 怎么选输入框用 focus: 很合适,因为无论鼠标还是键盘进入输入状态,都应该清楚显示当前字段。按钮更适合 focus-visible::键盘导航时提供强焦点轮廓,普通鼠标点击后则不必一直保留显眼外框。不要用 outline-none 抹掉焦点后什么都不补,那会让键盘用户失去当前位置。
invalid: 什么时候有用简单的 required、邮箱格式、最小最大值可以使用 invalid:*。但浏览器可能在用户还没提交前就认为空字段无效,复杂表单也常有跨字段和服务端规则。课程工作台用 aria-invalid 映射应用校验结果,展示时机更可控。两种方式可以共存,关键是错误状态要和真实校验同步。
表单样式最怕只完成默认和悬浮两张截图。真正交付前,把同一个控件的状态列出来检查,会比继续调圆角更有价值。
这张表把“行为”和“长相”分开了。比如禁用状态不能只写 opacity-50,因为用户仍可能点击;保存中不能只换成旋转图标,因为屏幕阅读器和不看动画的人需要文字;错误不能只出现红色,因为用户还不知道哪里错了。
输入框高度也要保持稳定。错误信息出现时,页面允许向下增加说明空间,但输入框本身不要突然改变边框宽度导致内容跳动。用同样的 border 宽度切换颜色,或用 ring 增强状态,通常比从 border 切到 border-2 更平稳。
如果按钮发起异步操作,成功提示应放在操作附近,必要时使用可被辅助技术感知的状态区域。Tailwind 可以控制提示的颜色、间距和出现方式,但“何时宣布状态”“失败后焦点去哪”仍是组件逻辑的责任。样式系统和交互逻辑各管一半,表单才算完整。
侧栏最后放“重置进度”。这是不可逆风险较高的操作,不能和“保存计划”长得一样。我们先做操作区,再做确认对话框。
export function DangerZone({ onReset }) {
return (
<section className="rounded-2xl border border-rose-200 bg-rose-50/50 p-5">
<h2 className="text-base font-semibold text-rose-950">重置学习进度</h2>
<p className="mt-1 text-sm/6 text-rose-800">
已完成记录会被清空,课程和笔记仍会保留。
</p>
<button
type="button"
onClick=
危险操作用浅红背景和红色文字表达语义,但主按钮仍留给“继续学习”和“保存计划”。颜色不是唯一提示,标题和说明也明确写出了后果。
确认对话框负责把用户从页面流中暂时带到一个单一决定:
export function ResetDialog({ open, onCancel, onConfirm }) {
if (!open) return null
return (
<div className="fixed inset-0 z-50 grid place-items-center p-4" role="dialog" aria-modal="true" aria-labelledby="reset-title">
<button
type="button"
className="absolute inset-0 bg-slate-950/60"
运行后,遮罩覆盖页面,面板在视口中央;手机上按钮纵向排列,并把主操作放在更靠近拇指的位置,sm 以上再恢复横排。
布局和视觉只是对话框的一半。真实项目还要处理初始焦点、Tab 焦点循环、Escape 关闭、关闭后把焦点还给触发按钮,以及页面滚动锁定。优先使用项目已有的无障碍对话框组件或原生 dialog 封装,不要因为 Tailwind 能画出一个弹层,就误以为交互语义也自动完成了。
transition-all按钮只会改变背景和文字颜色,所以使用 transition-colors duration-150 已经够用。transition-all 会把尺寸、位置等可能意外变化的属性也纳入过渡,排查问题更困难。阴影或位移确实要变化时,再选择对应属性或明确写出自定义过渡。
对必要动效,还要允许用户减少动画:
<article className="transition-[transform,box-shadow] duration-200 ease-out hover:-translate-y-0.5 hover:shadow-md motion-reduce:transform-none motion-reduce:transition-none">
{/* 课程卡片内容 */}
</article>这段过渡解释“卡片可操作、当前正被指向”,幅度只有半个间距单位。它不会让每个面板漂浮,也不会拖慢关键操作。
到这里,你可能又开始对 className 的长度不舒服了。这种不舒服有时是在提醒我们提取组件,有时只是在提醒我们把代码换行。判断边界时,我建议看“复用的是不是一个有行为和语义的东西”。
只有一个地方使用的面板,即使有十几个工具类,也可以先留在页面。它们全部贴着当前结构,改起来很安全。为了让标签看起来短一点就新建 .panel,反而把你送回标记与 CSS 文件之间来回跳转的老路。
当按钮在多个文件反复出现,并且共享尺寸、状态和语义时,提取 Button 组件比复制类名更合适:
const toneClasses = {
primary: "bg-sky-600 text-white hover:bg-sky-700 focus-visible:outline-sky-600",
neutral: "border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 focus-visible:outline-sky-600",
danger: "bg-rose-600 text-white hover:bg-rose-700 focus-visible:outline-rose-600",
}
export function Button({ tone = "primary", className = "", ...props }) {
return (
<button
{...props}
className
注意 toneClasses 保存的是完整类名。不要写成 `bg-${color}-600`。Tailwind 扫描源码时把文件当作文本查找类名,并不会运行 JavaScript 推导字符串;完整映射既能稳定生成样式,也允许每个语义变体选择不同对比度,而不是机械套同一色阶。
进度宽度来自数据,用内联样式或 CSS 变量。按钮色调来自有限设计选择,用完整类名映射。卡片标题来自内容,直接作为 children 或属性。三类变化分开后,组件 API 会清楚很多。
Tailwind 并不是 CSS 的终点。下面几类需求直接写 CSS 很正常:
约束带来的自由,前提是约束真的适合问题。工具类擅长组合常见界面原语;遇到它不擅长的选择器关系或动态计算,回到 CSS 并不算失败。
第一种情况是视觉语言高度定制。比如品牌页面大量依赖不规则剪裁、连续渐变、复杂纹理和彼此关联的伪元素。你当然可以把每个值都塞进方括号,但标记会充满一次性数字,主题刻度也没有真正参与设计。此时用语义清楚的自定义 CSS、SVG 或专门的视觉组件,通常比堆任意值更容易维护。
第二种情况是样式主要由运行时计算。拖拽编辑器、数据可视化、画布工具会不断产生坐标、角度和尺寸,这些值不是有限的设计变体。让 JavaScript 写 CSS 变量或元素样式,再让 Tailwind 负责外围布局、文字和状态,是更自然的分工。不要为了“全都用 Tailwind”把连续数据离散成几十个假工具类。
第三种情况是内容来自无法控制的外部 HTML。文章正文、富文本编辑器输出和第三方组件内部标记,可能没有机会逐个添加工具类。可以给稳定外壳使用 Tailwind,再用一段作用域明确的 CSS 处理后代元素。边界清楚比形式统一更重要。
第四种情况是团队协作成本已经超过收益。如果团队已有成熟的样式模块和组件约定,突然要求所有人把规则迁进标记,会让评审、排错和新人上手同时变慢。可以从新组件或高重复页面试点,先约定颜色、间距、组件提取和类名排序,再根据真实维护体验决定范围。
反过来,也不要因为一个页面有特殊视觉就否定 Tailwind。工作台这类由常规布局、表单、卡片和状态组成的产品界面,恰好能从统一刻度和局部样式中受益。选择工具时看主要矛盾:你是在管理大量常规界面的一致性,还是在描述少量高度独特的视觉作品?答案不同,最顺手的方案也会不同。
Tailwind 解决的是样式组合与约束问题,不会自动解决组件语义、数据状态、可访问性和产品信息架构。能用一个类画出焦点环,不等于焦点管理已经完成;能画出对话框,也不等于对话框行为已经正确。
提取的目标不是让每个 className 都变短,而是让页面只保留页面编排,让组件拥有自己的结构、状态和复用规则。按职责切开,比按字符数切开可靠。
前面每一步都在同一张页面上增加结构。最终页面组件不需要再重复样式细节,它只负责数据和编排:
const courses = [
{ id: 1, shortName: "TW", category: "界面开发", title: "Tailwind CSS 实战", progress: 68, href: "/learn/tailwind" },
{ id: 2, shortName: "JS", category: "编程基础", title: "现代 JavaScript", progress: 42, href: "/learn/javascript" },
{ id: 3, shortName: "UX", category: "产品设计", title: "可用性设计入门", progress: 21, href: "/learn/usability"
WorkspaceShell 把 Header 作为独立插槽放在 main 之前,这不是无关紧要的代码洁癖。全宽背景属于 Header,受最大宽度约束的导航内容属于 Header 内层容器;页面的垂直留白属于 Main。组件边界会反过来影响布局,职责放错一层,再漂亮的工具类也会让结构别扭。
现在可以把这些抽象放回一张可操作的工作台里验证。下面的组件组合器会同时展示导航、章节目录、课程卡片、进度辅助栏和对话框。操作前,先预测一下:切换布局列数时,哪些组件应该变,哪些内部状态应保持不变?
依次切换密度、强调色和布局列数,观察变化是停留在主题与页面编排层,还是泄漏到了组件内部。然后打开对话框,用键盘走一遍操作。如果外观切换迫使你重写业务状态,或组件换个位置就失效,边界还需要继续收紧。
如果其中某条说不清,先回到负责那层关系的父容器,而不是继续给子元素加补丁类。
先看约 360px 的窄屏。顶部导航只保留品牌和菜单,页面标题与“继续上次课程”按钮纵向排列。课程列表是一列,卡片内部也保持竖向;侧栏模块接在课程之后,表单控件占满宽度,对话框按钮纵向排列。此时不要追求把桌面信息全部塞进首屏,优先保证阅读顺序和可点击面积。
再看约 768px 的中等宽度。导航链接出现,课程列表切成两列,但外层主栏与侧栏仍可以保持单列。这个阶段最能检验容器查询:浏览器宽了,不代表每张卡也足够宽,卡片应该继续按自己拿到的空间决定内部结构。
最后看大屏。外层建立主栏和 20rem 侧栏,课程列表根据主栏宽度增加列数。侧栏如果内容较短,可以在产品确实需要时增加 lg:sticky lg:top-6,让进度与计划在滚动中保持可见;但侧栏高于视口、内部又有表单时,不要盲目 sticky,否则底部内容可能难以到达。
三个宽度阶段是同一个工作台逐步获得空间的过程。下图用一条成长的藤蔓连起手机、平板和桌面:基础内容一直保留,列数和侧栏只在空间允许时增加。

沿着藤蔓向右检查:每一次变化是“增加有空间才能承载的布局”,还是“先造一份桌面页,再在小屏拆掉”?前者的基础类更简单,断点只负责增量变化,这才是本章所说的移动优先。
<aside className="min-w-0 lg:sticky lg:top-6">
<SidebarStack>{/* 进度、计划、操作区 */}</SidebarStack>
</aside>这段效果只有在 lg 以上生效,小屏仍按正常文档流出现。加 sticky 前还要检查祖先是否设置了会影响它的 overflow,并确认侧栏总高度不会超过可视区域。工具类让尝试很快,但布局条件仍需要你亲自验证。
跨宽度检查时,还要用真实的长标题、零进度、百分之百进度、错误文字和保存中文案。只用整齐的短数据,很多溢出和跳动永远不会出现。组件可靠性来自边界数据,不来自默认截图。
Tailwind 写得快,也容易让人进入“再加一个类试试”的循环。下面这套顺序能帮你更快定位问题。
先检查结构。确认发生问题的元素究竟属于哪个父容器,父级当前是普通文档流、Flexbox 还是 Grid。很多对齐问题不是少了一个类,而是布局职责放错了层级。
再检查尺寸约束。Flex 或 Grid 子项是否需要 min-w-0,固定操作是否需要 shrink-0,图片或长文本是否设置了溢出策略。先让元素能正确收缩,再谈视觉。
接着检查响应条件。无前缀类是否真的是移动端基础状态,断点变体是否只做增量覆盖;组件内部依赖的是视口宽度还是容器宽度。
然后检查间距归属。容器内边距用 ,兄弟关系优先由父级 管理,单个语义距离再使用 。同一段空白不要被父子双方重复承担。
下面的练习都基于本章工作台,不再做互不相关的小色块。
给导航右侧增加搜索按钮、通知按钮和头像。要求品牌标题很长时能截断,三个操作保持成组,移动端只显示通知和菜单。
把 CourseCard 同时放入工作台两列网格和一个 w-72 的推荐栏。要求卡片在任意位置都不会因为浏览器很宽而挤成横排。
让计划表单经历“可编辑、正在保存、保存失败、保存成功”四种状态。要求状态不仅改变颜色,还改变可操作性和提示文字。
把进度面板和计划表单放进一个没有固定侧栏、只有流式网格的学习报告页。不能修改组件内部的视口断点来迁就新页面;如果组件确实要随自身宽度变化,使用容器查询。完成后在 320px、768px 和宽屏容器下逐项检查:文字是否溢出、操作是否可见、焦点是否清晰、禁用状态是否真的不可操作。
这道题的落点不是“又写了多少工具类”,而是组件离开原来的工作台后还能不能成立。能成立,说明页面编排、组件适配和交互状态已经分开;不能成立,就回到边界处调整,而不是给新页面再复制一份组件。
输入框获得焦点后,边框变为主题色,同时出现半透明焦点环。标签和说明仍在原位,用户不必依赖输入框内部文字来猜字段含义。
p-*gap-*mt-*最后检查交互和视觉。先确认原生属性与应用状态正确,再让 focus-*、disabled:*、aria-invalid:* 映射状态。阴影和过渡只在结构稳定后加入。