CSS 变量与暗黑模式:自定义属性实战

CSS 变量(官方名称是”CSS 自定义属性”,Custom Properties)是现代前端主题化与样式复用的基石。本文从 --name 与 var() 的基础语法讲起,手把手用 data-theme 与 prefers-color-scheme 实现一套可平滑切换、跟随系统的暗黑模式,并拆解 token 分层与高频踩坑,让你一次把主题系统做对。

一、什么是 CSS 自定义属性(CSS 变量)

过去我们用 Sass/Less 的变量在”编译期”替换字符串,产出的 CSS 里已经没有变量概念。而 CSS 变量是浏览器原生支持的”运行时变量”:它在页面加载后依然存在,可以被 JS 读取改写、可以被媒体查询和选择器作用域动态切换。正是这种运行时可变性,让暗黑模式、换肤、动态主题色成为可能。

命名上它以两根连字符开头(--),通过 var() 函数读取。两个连字符是强制前缀,不是风格偏好——浏览器正是靠它区分”自定义属性”和”普通属性”。

兼容性方面可以放心:所有现代浏览器(Chrome、Firefox、Safari、Edge)自 2017 年前后就已支持,生产环境无需降级方案。进阶场景还可以用 @property 给变量声明类型(如 <color>、<length>),让原本”无类型、不可动画”的自定义属性变得可被过渡和关键帧动画驱动,这在做颜色渐变主题时尤其好用。

二、为什么用 CSS 变量

2.1 一处修改,全局生效

把颜色、间距、圆角等”设计 token”集中声明在 :root,组件里只引用变量名。哪天品牌色要调整,改一行即可,不必全局搜索替换几十处硬编码色值。

2.2 运行时可改,主题切换的基石

编译期变量在打包后就固化了,而 CSS 变量在页面运行时可被 JS 或属性选择器重写。这就是暗黑模式不需要重新打包、不刷新即可切换的根本原因。它与现代网页布局(CSS Grid 与 Flexbox)配合,能构建既规整又可换肤的界面。

三、基础语法与用法

3.1 声明与读取

在 :root(代表文档根 <html>)里声明全局变量,任意元素用 var() 读取:

:root {
  --color-primary: #3b82f6;
  --color-bg: #ffffff;
  --space-md: 16px;
  --radius: 8px;
}

.btn {
  background: var(--color-primary);
  padding: var(--space-md);
  border-radius: var(--radius);
}

3.2 作用域与继承

CSS 变量像普通属性一样会继承。在 :root 声明的是全局变量;在某个选择器里声明则只作用于它及其后代,后代可覆盖。这让”局部主题”很容易实现:

:root { --accent: blue; }
.card { --accent: green; }   /* 仅 .card 及其子元素生效 */
.card .tag { color: var(--accent); }  /* 读到 green */

3.3 fallback 与多层兜底

var() 第二个参数是指定变量缺失时的回退值,可多层嵌套:

color: var(--text, var(--text-default, #333));

3.4 在 JS 中读写变量

主题切换按钮就靠这几行:

// 读取
const v = getComputedStyle(document.documentElement)
            .getPropertyValue('--color-primary');
// 写入(切换主题)
document.documentElement.style
        .setProperty('--color-primary', '#1e40af');

3.5 用 calc() 组合变量

变量可以参与计算,这是构建间距/字号尺度系统的关键。比如定义基础单位,再派生出整套间距,改动一处就能等比缩放整个排版:

:root {
  --unit: 4px;
  --space-sm: calc(var(--unit) * 2);   /* 8px  */
  --space-md: calc(var(--unit) * 4);   /* 16px */
  --space-lg: calc(var(--unit) * 8);   /* 32px */
}
.card { padding: var(--space-md); margin-bottom: var(--space-lg); }

注意 getPropertyValue 返回的是带首尾空格的字符串,比较前记得 trim();写入变量的值也应为合法 CSS 值,否则会被浏览器静默忽略。

四、用 CSS 变量实现暗黑模式

4.1 data-theme 手动切换方案

最可控的方式:在 <html> 上挂 data-theme="dark",用属性选择器覆盖变量:

:root {
  --color-bg: #ffffff;
  --color-text: #1f2937;
  --color-primary: #3b82f6;
}
:root[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-text: #e2e8f0;
  --color-primary: #60a5fa;
}
body {
  background: var(--color-bg);
  color: var(--color-text);
}

4.2 跟随系统 prefers-color-scheme

不写 JS 也能跟随系统暗色模式,适合”默认跟随、不提供手动开关”的场景:

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0f172a;
    --color-text: #e2e8f0;
  }
}

4.3 二者结合的完整方案

实战中推荐”系统优先、用户可覆盖、记忆选择”:默认跟随系统,用户点切换后把选择存到 localStorage,下次加载优先用用户选择。注意脚本要放在 <head> 尽早执行,避免首屏白底闪烁(FOUC)。

<script>
  const saved = localStorage.getItem('theme');
  const sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  const theme = saved || (sysDark ? 'dark' : 'light');
  document.documentElement.setAttribute('data-theme', theme);
</script>

<button id="toggle">切换主题</button>
<script>
  document.getElementById('toggle').onclick = () => {
    const cur = document.documentElement.getAttribute('data-theme');
    const next = cur === 'dark' ? 'light' : 'dark';
    document.documentElement.setAttribute('data-theme', next);
    localStorage.setItem('theme', next);
  };
</script>

五、进阶技巧

5.1 语义化 token 分层

直接把 --blue 写进组件会让换肤再次变得困难。推荐两层:底层 primitive 存具体色值,上层 semantic 表达”用途”,组件只引用语义层。这样换成绿色主题时只改语义层映射即可。

层级例子组件该用谁
Primitive–blue-500: #3b82f6不直接用
Semantic–color-primary: var(–blue-500)用这个

5.2 配合容器查询做局部主题

CSS 变量还能和 CSS 容器查询 组合:组件放进不同容器时,通过容器作用域重写变量,实现”同一个卡片在侧栏和主区用不同密度”。这是比媒体查询更精准的响应式思路。

5.3 平滑切换 transition

给会变化的属性加过渡,切换不再生硬:

body {
  background: var(--color-bg);
  color: var(--color-text);
  transition: background-color .3s ease, color .3s ease;
}

5.4 用变量搭间距与字号尺度

除了颜色,间距、圆角、阴影、字号同样适合变量化。把它们统一收进一套”尺度(scale)”,界面会自然保持一致,也方便做无障碍适配——例如用户放大系统字号时,用变量整体抬升基准值即可。结合 Vite 前端构建,还能在构建期校验 token 是否被引用,防止出现”声明了却没人用”的死变量。

:root {
  --fs-sm: 14px; --fs-md: 16px; --fs-lg: 20px;
  --radius-sm: 6px; --radius-md: 10px;
  --shadow-1: 0 1px 3px rgba(0,0,0,.12);
}
@media (prefers-reduced-motion: no-preference) {
  body { transition: background-color .3s ease; }
}

六、性能与避坑

6.1 高频动画属性慎用 var() 重算

修改 CSS 变量可能触发重排/重绘。涉及动画时,应动画 transform、opacity 这类只触发合成层的属性,避免每帧大量重算变量导致的卡顿。原理可参考浏览器渲染:从关键渲染路径到重排重绘。

6.2 CSS 变量 ≠ Sass 变量

很多人混淆两者。下面这张表说清区别:

维度CSS 变量Sass 变量
生效时机浏览器运行时编译期替换
能否 JS 改能不能
作用域DOM 继承词法作用域
典型用途主题/换肤/动态值构建期复用

6.3 覆盖顺序与优先级

CSS 变量同样遵循层叠优先级:内联 style 覆盖属性选择器,属性选择器覆盖 :root。调试主题不生效时,先用开发者工具看变量最终计算值来自哪条规则,多半是优先级被覆盖。

七、30 秒上手清单

步骤动作
1在 :root 声明 semantic token(背景/文字/主色)
2组件全部用 var() 引用,禁止硬编码色值
3:root[data-theme=”dark”] 覆盖一套暗色 token
4head 内早执行脚本读 localStorage + 系统偏好
5切换按钮写 setProperty + 持久化
6给 body 加 transition 平滑过渡

把变量当”设计系统的接口”来用,主题切换、品牌换肤、暗黑模式都只是改这套接口的实现。配合 Vue3 组合式 API 等框架的响应式状态,还能把主题做成可全局注入的响应式对象,进一步提升工程化体验。

上一篇 MyBatis 实战:Java 持久层框架从入门到避坑
下一篇 Grafana 监控面板搭建实战:可视化与告警配置
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]