前置知识: Tailwind CSS

Tailwind CSS 响应式与暗色模式

11 min中级

Tailwind CSS 响应式与暗色模式原理篇:从移动优先断点与 prefers-color-scheme 媒体查询讲起,掌握 sm:/md:/lg: 前缀、dark: 变体与 @custom-variant 策略切换,附 data-theme 生产级换肤实战

前置知识

学习目标

  • 能解释媒体查询与移动优先策略,看懂断点前缀编译后的 CSS。
  • 能用 dark: 变体做跟随系统的暗色适配,并用 @custom-variant 切换为 class / data 属性策略。
  • 能落地一套”localStorage 记忆 + 系统偏好兜底 + 绘制前设置”的生产级换肤,且不闪白。
  • 能组合断点与状态变体(如 dark:hover:、md:hidden)表达复合条件。

0. 先打个比方:给”变形金刚”准备多套衣服

你有没有想过,变形金刚为什么能同时适应汽车和机器人两种形态?因为它有一套”变形逻辑”:根据当前环境,决定展示哪套外观。

网页也面临同样的问题:同一份内容,要在手机(375px 宽)、平板(768px 宽)、电脑(1440px 宽)上都能正常阅读。更麻烦的是,同一个用户可能白天用亮色界面、晚上用暗色界面。响应式设计和暗色模式,就是网页的”变形逻辑”。

Tailwind CSS 用一套非常聪明的语法解决了这个问题:把”变形条件”(视口宽度、系统明暗偏好)写成类名前缀,比如 md:grid-cols-2 表示”当屏幕达到平板宽度时变成两列”,dark:bg-gray-900 表示”当系统处于暗色偏好时换成深色背景”。

本篇采用原理驱动的讲法:先搞懂响应式和暗色模式背后的 CSS 原理,再学习 Tailwind 的语法糖,最后看代码。原理清楚了,再复杂的布局你都能自己推理出来。

1. 响应式原理:从媒体查询说起

1.1 直观理解:一道”宽度闸门”

响应式的本质,是 CSS 的媒体查询(Media Query)。它就像一道”宽度闸门”:当浏览器窗口宽度达到某个值,闸门打开,闸门内的样式才生效。

css
/* 原生 CSS:当视口宽度 ≥ 768px 时,.card 变成两列布局 */
@media (min-width: 768px) {
  .card { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

1.2 移动优先:从最窄开始写

Tailwind 采用**移动优先(mobile-first)**策略,理解这一点是掌握响应式的关键:

  • 不加前缀的类(如 grid-cols-1):默认作用于所有屏幕,包括手机;
  • 带前缀的类(如 md:grid-cols-2):仅在视口”达到该宽度及以上”时生效。

就像变形金刚默认是”汽车形态”(手机最常用),只有满足条件才切换成其他形态。这种策略的工程理由是:手机端是流量大头,且”从窄到宽逐级增强”的思维比”从宽到窄逐步降级”更容易维护。

html
<!-- 移动优先示例:默认 1 列,平板 2 列,桌面 4 列 -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
  <div>卡片 1</div>
  <div>卡片 2</div>
  <div>卡片 3</div>
  <div>卡片 4</div>
</div>

1.3 原理:编译后的 CSS 长什么样

上面的写法,Tailwind 会编译成下面这段原生 CSS——每一个前缀类都被包进对应的 @media (min-width: ...) 媒体查询里:

css
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }

/* md: 前缀的类被包进 768px 闸门 */
@media (min-width: 768px) {
  .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* lg: 前缀的类被包进 1024px 闸门 */
@media (min-width: 1024px) {
  .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

看懂这段编译结果,你就明白了响应式的全部真相:断点前缀不过是一个”自动包裹媒体查询”的语法糖,类名之间互不干扰,浏览器自己根据当前视口宽度决定哪些媒体查询内的规则生效。

2. 断点体系:默认的五档宽度

Tailwind 4 内置五档断点(在 @theme 中以 --breakpoint-* 变量存在,可自定义):

前缀最小宽度对应媒体查询典型设备
(无前缀)0无手机
sm:640px(40rem)@media (width >= 640px)大屏手机 / 小平板
md:768px(48rem)@media (width >= 768px)平板
lg:1024px(64rem)@media (width >= 1024px)笔记本
xl:1280px(80rem)@media (width >= 1280px)桌面显示器
2xl:1536px(96rem)@media (width >= 1536px)大屏显示器

注意:Tailwind 4 编译输出的媒体查询写法是 @media (width >= 768px) 这种新式语法,与传统 @media (min-width: 768px) 等价,语义更直观。旧浏览器会自动被工具降级处理。

2.1 自定义断点

断点也是设计令牌,在 @theme 中修改即可(承接上一篇的主题定制知识):

css
@theme {
  /* 覆盖默认断点:把 sm 从 640px 改为 560px */
  --breakpoint-sm: 560px;

  /* 新增断点:自动生成 3xl: 前缀 */
  --breakpoint-3xl: 1920px;
}
html
<div class="grid grid-cols-1 3xl:grid-cols-4">超大屏 4 列</div>

2.2 任意断点 min-[…]

当预设断点都不合适时,用任意值语法精确控制(生成 @media (width >= 880px)):

html
<div class="grid grid-cols-1 min-[880px]:grid-cols-3">
  当视口宽度 ≥ 880px 时变为三列
</div>

建议:任意断点适合”设计稿刚好在非标准宽度断列”的一次性场景,但应控制数量,否则断点碎片化会严重增加维护成本。

3. 移动优先的思维模式:先写”地板”,再铺”台阶”

响应式布局的推荐写法遵循”渐进增强”原则——先保证手机端可用,再逐档增强:

html
<!-- 先写手机端(基础样式),再逐级增强 -->
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold">
  响应式标题:手机 2xl,平板 3xl,桌面 4xl
</h1>

<section class="py-8 md:py-12 lg:py-16 px-4 md:px-8">
  响应式内边距
</section>

也可以反向理解”不要做什么”:不要写 lg:text-lg md:text-base text-sm 这种”从大往小降级”的排列,它会让人难以判断基础样式是什么。基础样式(无前缀)永远在最前,增强样式按断点从小到大排列。

4. 暗色模式原理:prefers-color-scheme

4.1 直观理解:系统的一道”明暗闸门”

和媒体查询一样,CSS 原生就支持按用户的系统明暗偏好来切换样式,靠的是 prefers-color-scheme 媒体特性:

css
/* 原生 CSS:系统处于暗色偏好时,页面换深色背景 */
@media (prefers-color-scheme: dark) {
  body { background-color: #111827; color: #f3f4f6; }
}

这就像变形金刚感知到”天黑自动切换夜行形态”。用户不需要在网页上做任何操作——操作系统设置成深色模式,网页自动变暗。

4.2 dark: 变体:Tailwind 的暗色语法糖

Tailwind 4 把这道”明暗闸门”封装成 dark: 前缀。无需任何配置,开箱即用:

html
<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
  亮色下白底黑字,暗色下黑底白字
</div>

原理和断点完全一样:dark:bg-gray-900 会被编译成 @media (prefers-color-scheme: dark) { .dark\:bg-gray-900 { ... } }。

4.3 与主题令牌配合的推荐写法

结合上一篇的主题定制,把亮/暗两套取值定义为语义令牌,dark: 负责切换,避免组件里散落大量颜色值:

css
@theme {
  --color-surface: #ffffff;
  --color-surface-dark: #141414;
  --color-text-main: #1f1f1f;
  --color-text-dark: #e5e5e5;
}
html
<div class="bg-surface text-text-main dark:bg-surface-dark dark:text-text-dark">
  语义令牌 + dark 变体:主题更可控
</div>

5. class / data 属性策略:让用户手动切换主题

5.1 为什么需要手动切换

系统策略(跟随系统偏好)适合”开箱即用”,但真实产品通常还要提供”用户手动切换”的功能——用户可能想在系统亮色时把网站调成暗色。这时 prefers-color-scheme 就不够用了,我们需要换一种策略:由 JS 在 <html> 元素上做一个标记,dark: 变体检测这个标记是否存在。标记可以是 .dark 类,也可以是 data-theme 属性。

5.2 原理:@custom-variant 重新定义 dark

Tailwind 4 用 @custom-variant 指令重新定义 dark: 变体的匹配条件:

css
/* src/styles/global.css */
@import "tailwindcss";

/* 写法一:class 策略——当祖先元素存在 .dark 类时生效 */
@custom-variant dark (&:where(.dark, .dark *));

/* 写法二:data 属性策略——当祖先元素 data-theme="dark" 时生效 */
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

两种写法二选一。&:where(...) 是 CSS 选择器语法:& 代表当前元素,后半部分是匹配条件(自身或祖先带标记)。合起来就是:只要祖先树里出现标记,dark: 样式就生效。:where() 的选择器优先级恒为 0,保证不会干扰其他样式规则。

5.3 生产级实战:data-theme + 绘制前内联脚本

手写切换只是两行 JS,但生产环境要多想三步。下面这套结构来自 FANDEX 仓库的真实实现,可以直接照抄:

第一步,主题标记要能直接当 CSS 变量的作用域用。令牌文件按选择器分亮暗两套值(令牌体系见第 5 篇):

css
/* tokens.css(节选):变量名相同,值随作用域切换 */
:root {
  color-scheme: light;
  --fandex-color-surface: #ffffff;
}

[data-theme='dark'] {
  color-scheme: dark;
  --fandex-color-surface: #141414;
}

第二步,切换脚本必须内联在 <head>、同步执行。放在底部或等 DOMContentLoaded 都会晚于首帧,深色用户会先看到一帧白底再跳黑——这个”闪白”(FOUC)一旦上线就很难被容忍:

html
<head>
  <script>
    // 内联同步执行:必须在浏览器绘制第一帧之前设好 data-theme
    (function () {
      var saved = localStorage.getItem('theme')
      var theme = saved ?? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
      document.documentElement.dataset.theme = theme
    })()
  </script>
</head>

这段脚本同时处理了三件事:读用户的手动选择(localStorage)、没有选择时兜底系统偏好、在绘制前把标记挂到 <html> 上。

第三步,页面上提供一个三态切换(亮 / 暗 / 跟随系统):

js
// theme-manager.js —— 支持"亮色 / 暗色 / 跟随系统"三态
function applyTheme(theme) {
  const root = document.documentElement
  if (theme === 'system') {
    localStorage.removeItem('theme')
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
    root.dataset.theme = prefersDark ? 'dark' : 'light'
  } else {
    localStorage.setItem('theme', theme)
    root.dataset.theme = theme
  }
}

FANDEX 选择 data-theme 属性而非 .dark 类还有一个架构理由:属性选择器可以直接作为令牌文件的作用域(第一步的 [data-theme='dark'] { ... }),令牌层不必依赖 Tailwind 的变体机制就能完成换肤,令牌与工具类从此解耦。另外注意:CSS 原生的 light-dark() 函数在这里帮不上忙——Tailwind 4 的解析器在 @theme 块之外不支持 light-dark() 与 var() 混合写法,这也是生产项目普遍选择选择器方案的原因。

5.4 两种策略怎么选

策略写法适合
class@custom-variant dark (&:where(.dark, .dark *))简单项目、跟随 shadcn/ui 等社区惯例
data 属性@custom-variant dark (&:where([data-theme='dark'], ...))有独立令牌体系、需要亮暗之外多主题(如 data-theme='sepia')的项目

两种策略只是选择器不同,原理完全一致。扩展多个主题时 data 属性天然占优:data-theme 的取值可以继续加 high-contrast、sepia 等档位,每档一组变量即可。

6. 响应式与暗色的组合:变体叠加

6.1 变体可以任意叠加

Tailwind 的变体(断点前缀、状态前缀、暗色前缀)可以像积木一样叠加,顺序自由,语义从右往左读:

html
<button class="bg-blue-600 px-4 py-2 text-white rounded-md
               hover:bg-blue-700
               dark:bg-blue-500 dark:hover:bg-blue-400
               md:px-6">
  叠加变体的按钮
</button>
  • dark:hover:bg-blue-400:暗色模式下悬停时变亮蓝;
  • md:px-6:桌面端加大内边距。

编译结果会生成 @media (prefers-color-scheme: dark)(或你自定义的选择器)内的 :hover 规则、@media (width >= 768px) 内的规则,各归其位、互不干扰。

6.2 典型示例:响应式导航栏

导航栏是响应式 + 暗色的经典组合场景:

html
<nav class="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-900">
  <a href="/" class="font-bold dark:text-white">Logo</a>

  <!-- 移动端隐藏、桌面端显示的菜单 -->
  <ul class="hidden md:flex gap-6">
    <li><a class="dark:text-gray-300" href="/docs">文档</a></li>
    <li><a class="dark:text-gray-300" href="/blog">博客</a></li>
  </ul>

  <!-- 仅移动端显示的菜单按钮(反向控制) -->
  <button class="md:hidden">菜单按钮</button>
</nav>

两个关键模式:

  • hidden md:flex:默认隐藏,平板及以上显示(渐进增强);
  • md:hidden:默认显示,平板及以上隐藏(反向控制)。

7. 坑点与自检

常见错误报错 / 现象原因解决办法
只写了 md:grid-cols-2 没写基础类手机上永远是默认布局无前缀类才是基础样式,前缀类只在对应宽度生效先写无前缀基础类(如 grid-cols-1),再写增强类
断点类顺序写反(lg:... md:... 从大到小)行为诡异、难以排查移动优先要求从小到大排列,保证代码可读基础类在前,断点从小到大
想用手动切换但没写 @custom-variantdark: 一直跟随系统,JS 切类无效默认策略是 prefers-color-scheme,不是 class/data在 CSS 中加入 @custom-variant dark (...)
dark: 写在没有祖先标记的元素上暗色样式不生效class/data 策略要求标记在元素祖先链上把标记加到 <html> 上(document.documentElement)
切换脚本放在页面底部深色用户首帧闪白标记设置晚于首帧绘制脚本内联在 <head> 同步执行
深色模式下忘记处理图片/阴影图片过亮、阴影突兀dark: 只覆盖显式书写的类给图片加 dark:opacity-80、阴影换 dark:shadow-none 等
拼接动态类名 bg-${color}-500样式缺失内容扫描只识别完整类名,无法解析拼接使用完整类名,或用映射表(如 const map = { red: 'bg-red-500' })

自检清单:

  • 能手写出 md:grid-cols-2 编译后的媒体查询结构
  • 能解释”移动优先”为什么要求基础类在前、断点从小到大
  • 能默写 @custom-variant 的 class 与 data 属性两种写法
  • 能说出换肤脚本为什么必须内联在 <head> 同步执行
  • 能把”亮 / 暗 / 跟随系统”三态切换完整实现一遍

8. 动手实践

  1. 三档卡片墙:实现一个卡片列表,手机 1 列、平板 2 列、桌面 3 列,再用 @theme 把 lg 断点改成 1100px,观察布局变化。提示:改完断点后记得检查其他用 lg: 的地方是否被连带影响——这就是”覆盖默认令牌影响面大”的体感。
  2. 无闪白换肤:按 5.3 节完整实现 data-theme 换肤(令牌两套值 + head 内联脚本 + 三态切换按钮),然后把内联脚本临时挪到 </body> 前,用 DevTools 的 Performance 面板或肉眼观察首帧闪白。提示:把系统设为深色后再刷新,差异最明显。
  3. 复合变体阅读训练:解释 dark:md:hover:bg-blue-400 在什么时候生效(先口头说,再用浏览器改窗口宽度 + 开发者工具模拟 prefers-color-scheme 验证)。提示:语义从右往左读——悬停时、且视口达 md、且暗色。

9. 一句话记忆

响应式 = “移动优先”断点闸门(sm:/md:/lg: 只是自动包一层媒体查询);暗色 = dark: 变体(默认听系统,@custom-variant 后听 .dark 类或 data-theme 属性);生产级换肤三件套——令牌分作用域、标记绘制前设置、三态记忆。

10. 下一步

  • 令牌与换肤的完整管线(JSON 源 -> tokens.css -> @theme inline 桥接)见主题定制与设计令牌。
  • 组件内部的亮暗适配与状态联动(group-hover: 等)见组件复用。