Design Token 驱动暗色主题——CSS 变量工程化实践
文章目录

每日一句正能量
往上爬的时候要对别人好一点,因为你走下坡的时候会碰到他们。
在高处时释放的善意,是在为未来可能的低谷储备善意与帮助。真正的远见,包含对他人命运的共情,世界是一个圆,今日你如何对待他人,可能决定了明日你被如何对待。
摘要
打开 Codex 官网,你首先感受到的不是某个功能,而是一种「氛围」:深邃的暗色背景、克制的文字层级、精准的品牌绿点缀。这种视觉一致性并非偶然,而是建立在一套严密的 Design Token 体系之上。本文将拆解 Codex 官网暗色主题的色彩工程,从 CSS 自定义属性到 Tailwind 集成,再到 SSR 兼容的无闪烁主题切换,给出完整的落地代码。
一、为什么 Design Token 是暗色主题的基石
在传统的前端开发中,颜色值往往以硬编码的形式散落在各个 CSS 文件里:#0a0a0f 写在 body 的背景上,#f0f0f5 写在标题里,#10a37f 写在按钮中。当产品需要支持暗色/亮色双主题时,开发者不得不进行全局搜索替换,风险高、维护难。
Design Token 的本质是将设计决策抽象为语义化变量。它不直接描述「这个按钮是绿色」,而是描述「这个按钮使用品牌强调色」。当主题切换时,只需要改变 --color-accent 的值,所有引用该变量的组件会自动适配。这正是 Codex 官网能够在暗色主题下保持视觉统一的核心机制。
二、Codex 官网暗色主题的视觉层次拆解
通过浏览器开发者工具对 Codex 官网进行样式审查,可以观察到其暗色主题的色彩系统具有清晰的层级结构。以下是对其视觉层次的还原分析:
2.1 背景色的三级递进
Codex 官网的背景并非单一的纯黑,而是通过三个层级的灰度递进营造空间感:
| Token | 色值 | 用途 |
|---|---|---|
--bg-primary |
#0a0a0f |
页面最底层背景,接近纯黑但带有一丝蓝调 |
--bg-secondary |
#12121a |
卡片、面板的背景色,与主背景形成微弱对比 |
--bg-elevated |
#1a1a24 |
悬浮层、弹窗、下拉菜单的背景,层级最高 |
这种递进策略避免了「一片死黑」的单调感。#0a0a0f 到 #1a1a24 的亮度差仅为约 6%,在人眼可感知的最小对比阈值之上,既区分了层级,又保持了暗色主题的沉浸感。
2.2 文字色的三级对比
暗色主题下的文字色设计比亮色主题更敏感。Codex 采用了三个层级的文字色:
| Token | 色值 | 用途 |
|---|---|---|
--text-primary |
#f0f0f5 |
标题、正文,接近纯白但略微降饱和 |
--text-secondary |
#a0a0b0 |
副标题、描述文字,降低阅读压力 |
--text-muted |
#606070 |
禁用状态、占位符、时间戳等辅助信息 |
#f0f0f5 而非纯 #ffffff 的选择非常讲究:纯白色在暗色背景上会产生光晕效应(halation),导致视觉疲劳。略微降低亮度和增加蓝调,可以在保持高对比度的同时提升长时间阅读的舒适度。
2.3 边框与强调色
Codex 的边框系统采用了极低的透明度策略:
| Token | 色值 | 用途 |
|---|---|---|
--border-default |
rgba(255,255,255,0.1) |
默认分隔线、卡片边框 |
--border-hover |
rgba(255,255,255,0.2) |
Hover 状态下的边框提亮 |
--color-accent |
#10a37f |
品牌绿,用于 CTA 按钮、链接、焦点状态 |
品牌绿 #10a37f 是 OpenAI 产品线的标志性颜色,在暗色背景上具有极高的辨识度。它的使用被严格限制在「需要用户行动」的元素上,形成了清晰的视觉引导。
以下这张图完整呈现了 Codex 官网的 Design Token 体系:

三、构建完整的 CSS 自定义属性体系
基于上述分析,我们可以构建一套完整的 CSS 自定义属性(CSS Variables)文件。这套体系需要满足三个条件:语义化命名、层级清晰、易于扩展。
/* styles/theme.css */
/* ============================================
基础色彩系统 - 不直接用于组件,作为 Token 的原材料
============================================ */
:root {
/* 品牌色 */
--color-brand-50: #e6f7f2;
--color-brand-100: #b0e8d6;
--color-brand-200: #8addc4;
--color-brand-300: #54ceaa;
--color-brand-400: #33c49b;
--color-brand-500: #10a37f;
--color-brand-600: #0f9473;
--color-brand-700: #0b745a;
--color-brand-800: #095a46;
--color-brand-900: #074436;
/* 中性色 - 暗色主题专用 */
--color-neutral-0: #ffffff;
--color-neutral-50: #f0f0f5;
--color-neutral-100: #d1d1db;
--color-neutral-200: #a0a0b0;
--color-neutral-300: #707080;
--color-neutral-400: #606070;
--color-neutral-500: #4a4a5a;
--color-neutral-600: #3a3a4a;
--color-neutral-700: #2a2a3a;
--color-neutral-800: #1a1a24;
--color-neutral-900: #12121a;
--color-neutral-950: #0a0a0f;
}
/* ============================================
暗色主题 Token - 语义化映射
============================================ */
[data-theme="dark"],
:root {
/* 背景层级 */
--bg-primary: var(--color-neutral-950); /* #0a0a0f */
--bg-secondary: var(--color-neutral-900); /* #12121a */
--bg-elevated: var(--color-neutral-800); /* #1a1a24 */
--bg-glass: rgba(255, 255, 255, 0.05); /* 毛玻璃效果 */
--bg-overlay: rgba(0, 0, 0, 0.7); /* 模态遮罩 */
/* 文字层级 */
--text-primary: var(--color-neutral-50); /* #f0f0f5 */
--text-secondary: var(--color-neutral-200); /* #a0a0b0 */
--text-muted: var(--color-neutral-400); /* #606070 */
--text-inverse: var(--color-neutral-950); /* 深色背景上的反白文字 */
/* 边框层级 */
--border-default: rgba(255, 255, 255, 0.1);
--border-hover: rgba(255, 255, 255, 0.2);
--border-focus: var(--color-brand-500); /* #10a37f */
/* 强调色 */
--color-accent: var(--color-brand-500);
--color-accent-hover: var(--color-brand-400);
--color-accent-active: var(--color-brand-600);
/* 状态色 */
--color-success: #10a37f;
--color-warning: #f5a623;
--color-error: #e74c3c;
--color-info: #3498db;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 40px rgba(16, 163, 127, 0.15);
/* 间距 - 4px 基线 */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* 圆角 */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
/* 字体 */
--font-sans: 'Söhne', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'Söhne Mono', 'SF Mono', Monaco, 'Cascadia Code', monospace;
--font-display: 'Söhne Breit', var(--font-sans);
/* 过渡 */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
--transition-slow: 350ms ease;
}
/* ============================================
亮色主题 Token - 完整覆盖
============================================ */
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f7;
--bg-elevated: #ffffff;
--bg-glass: rgba(0, 0, 0, 0.05);
--bg-overlay: rgba(0, 0, 0, 0.5);
--text-primary: #1a1a2e;
--text-secondary: #666666;
--text-muted: #999999;
--text-inverse: #ffffff;
--border-default: rgba(0, 0, 0, 0.1);
--border-hover: rgba(0, 0, 0, 0.2);
--border-focus: var(--color-brand-500);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
--shadow-glow: 0 0 40px rgba(16, 163, 127, 0.1);
}
/* ============================================
全局基础样式
============================================ */
html {
color-scheme: dark;
}
html[data-theme="light"] {
color-scheme: light;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color var(--transition-base),
color var(--transition-base);
}
四、color-scheme 与 prefers-color-scheme 的配合
CSS 的 color-scheme 属性是一个容易被忽视但极其重要的原生特性。它告诉浏览器当前页面支持的颜色主题,浏览器会根据这个值调整表单控件、滚动条等原生 UI 的样式。
html {
color-scheme: dark; /* 告诉浏览器:我支持暗色主题 */
}
当用户操作系统设置为暗色模式时,prefers-color-scheme: dark 媒体查询会自动生效。我们可以利用这一特性实现「跟随系统」的默认主题:
/* 默认跟随系统偏好 */
@media (prefers-color-scheme: dark) {
:root {
/* 暗色 Token 已在上文定义,此处继承 */
}
}
@media (prefers-color-scheme: light) {
:root {
/* 亮色 Token 覆盖 */
--bg-primary: #ffffff;
--text-primary: #1a1a2e;
/* ... 其他亮色 Token */
}
}
需要注意的是,prefers-color-scheme 只应作为默认行为。一旦用户通过界面手动切换了主题,就应当以用户的显式选择为准,不再跟随系统变化。
五、Tailwind CSS 集成:从 Token 到工具类
Tailwind CSS 的强大之处在于其原子化工具类,但如果直接将 #10a37f 这样的硬编码值写在 text-[#10a37f] 中,就丧失了 Design Token 的可维护性。正确的做法是将 Token 注入 Tailwind 的配置体系。
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: ['class', '[data-theme="dark"]'], // 使用 data-attribute 驱动暗色模式
theme: {
extend: {
colors: {
// 直接映射 CSS 变量,实现 Token 与 Tailwind 的无缝衔接
bg: {
primary: 'var(--bg-primary)',
secondary: 'var(--bg-secondary)',
elevated: 'var(--bg-elevated)',
glass: 'var(--bg-glass)',
overlay: 'var(--bg-overlay)',
},
text: {
primary: 'var(--text-primary)',
secondary: 'var(--text-secondary)',
muted: 'var(--text-muted)',
inverse: 'var(--text-inverse)',
},
border: {
DEFAULT: 'var(--border-default)',
hover: 'var(--border-hover)',
focus: 'var(--border-focus)',
},
accent: {
DEFAULT: 'var(--color-accent)',
hover: 'var(--color-accent-hover)',
active: 'var(--color-accent-active)',
},
brand: {
50: 'var(--color-brand-50)',
100: 'var(--color-brand-100)',
200: 'var(--color-brand-200)',
300: 'var(--color-brand-300)',
400: 'var(--color-brand-400)',
500: 'var(--color-brand-500)',
600: 'var(--color-brand-600)',
700: 'var(--color-brand-700)',
800: 'var(--color-brand-800)',
900: 'var(--color-brand-900)',
},
},
backgroundColor: {
primary: 'var(--bg-primary)',
secondary: 'var(--bg-secondary)',
elevated: 'var(--bg-elevated)',
},
textColor: {
primary: 'var(--text-primary)',
secondary: 'var(--text-secondary)',
muted: 'var(--text-muted)',
},
borderColor: {
DEFAULT: 'var(--border-default)',
hover: 'var(--border-hover)',
focus: 'var(--border-focus)',
},
boxShadow: {
sm: 'var(--shadow-sm)',
md: 'var(--shadow-md)',
lg: 'var(--shadow-lg)',
glow: 'var(--shadow-glow)',
},
borderRadius: {
sm: 'var(--radius-sm)',
md: 'var(--radius-md)',
lg: 'var(--radius-lg)',
xl: 'var(--radius-xl)',
},
fontFamily: {
sans: ['var(--font-sans)'],
mono: ['var(--font-mono)'],
display: ['var(--font-display)'],
},
spacing: {
'1': 'var(--space-1)',
'2': 'var(--space-2)',
'3': 'var(--space-3)',
'4': 'var(--space-4)',
'5': 'var(--space-5)',
'6': 'var(--space-6)',
'8': 'var(--space-8)',
'10': 'var(--space-10)',
'12': 'var(--space-12)',
'16': 'var(--space-16)',
},
transitionDuration: {
fast: '150ms',
base: '250ms',
slow: '350ms',
},
},
},
plugins: [
// 自定义插件:添加 text-balance 工具类
function({ addUtilities }: { addUtilities: Function }) {
addUtilities({
'.text-balance': {
'text-wrap': 'balance',
},
});
},
],
};
export default config;
配置完成后,你可以在组件中这样使用:
// 完全由 Token 驱动,无需硬编码任何颜色值
<button className="bg-accent hover:bg-accent-hover text-inverse px-6 py-3 rounded-md shadow-glow transition-fast">
开始使用 Codex
</button>
<div className="bg-secondary border border-border hover:border-border-focus rounded-lg p-6">
<h2 className="text-primary font-display text-2xl">功能特性</h2>
<p className="text-secondary mt-2">由 AI 驱动的编程助手</p>
</div>
六、无闪烁主题切换器:SSR 兼容方案
主题切换最容易遇到的问题就是 FOUC(Flash of Unstyled Content)——页面先以默认主题渲染,然后 JavaScript 执行后突然切换到用户选择的主题,造成视觉闪烁。在 SSR 场景下,这个问题尤为明显,因为服务端渲染时无法访问 localStorage。
6.1 核心思路
解决 FOUC 的关键在于:在 HTML 解析的最早期(<head> 内)就确定主题,并通过内联脚本立即应用。这个脚本必须在任何 CSS 渲染之前执行。
6.2 完整实现
// components/ThemeScript.tsx
// 这个组件必须在 _document.tsx 的 <Head> 中最早插入
export const ThemeScript = () => {
const script = `
(function() {
// 1. 尝试从 localStorage 读取用户显式选择
const stored = localStorage.getItem('theme');
// 2. 如果有存储值,直接使用
if (stored === 'dark' || stored === 'light') {
document.documentElement.setAttribute('data-theme', stored);
document.documentElement.classList.add(stored);
document.documentElement.style.colorScheme = stored;
return;
}
// 3. 否则跟随系统偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = prefersDark ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.classList.add(theme);
document.documentElement.style.colorScheme = theme;
})();
`;
return <script dangerouslySetInnerHTML={{ __html: script }} />;
};
// pages/_document.tsx
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document';
import { ThemeScript } from '@/components/ThemeScript';
export default class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
return await Document.getInitialProps(ctx);
}
render() {
return (
<Html lang="zh-CN">
<Head>
{/* ThemeScript 必须是最早的脚本,在 CSS 之前执行 */}
<ThemeScript />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
// hooks/useTheme.ts
import { useState, useEffect, useCallback } from 'react';
type Theme = 'dark' | 'light';
export function useTheme() {
const [theme, setThemeState] = useState<Theme>('dark');
const [mounted, setMounted] = useState(false);
useEffect(() => {
// 客户端挂载后,从 DOM 读取实际主题(避免 SSR 水合不匹配)
const current = document.documentElement.getAttribute('data-theme') as Theme || 'dark';
setThemeState(current);
setMounted(true);
}, []);
const setTheme = useCallback((newTheme: Theme) => {
const root = document.documentElement;
// 移除旧主题
root.classList.remove('dark', 'light');
// 应用新主题
root.classList.add(newTheme);
root.setAttribute('data-theme', newTheme);
root.style.colorScheme = newTheme;
// 持久化
localStorage.setItem('theme', newTheme);
setThemeState(newTheme);
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === 'dark' ? 'light' : 'dark');
}, [theme, setTheme]);
// 监听系统主题变化(仅在用户未手动选择时)
useEffect(() => {
if (!mounted) return;
const handler = (e: MediaQueryListEvent) => {
const stored = localStorage.getItem('theme');
if (!stored) {
setTheme(e.matches ? 'dark' : 'light');
}
};
const mql = window.matchMedia('(prefers-color-scheme: dark)');
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [mounted, setTheme]);
return { theme, setTheme, toggleTheme, mounted };
}
// components/ThemeToggle.tsx
'use client';
import { useTheme } from '@/hooks/useTheme';
export function ThemeToggle() {
const { theme, toggleTheme, mounted } = useTheme();
// 避免 SSR 水合不匹配:挂载前不渲染具体内容
if (!mounted) {
return <div className="w-10 h-10 rounded-md bg-secondary animate-pulse" />;
}
return (
<button
onClick={toggleTheme}
className="relative w-10 h-10 rounded-md bg-secondary hover:bg-elevated border border-border hover:border-border-focus flex items-center justify-center transition-fast"
aria-label={theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'}
>
{theme === 'dark' ? (
<svg className="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg className="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</button>
);
}
6.3 为什么这个方案能消除 FOUC
- 内联脚本在
<head>中最早执行:在浏览器解析<body>之前,主题已经通过data-theme和class应用到<html>上。 - CSS 变量即时生效:
theme.css中定义的所有变量都基于[data-theme]选择器,主题确定后样式立即正确。 - 无
localStorage的 SSR 风险:服务端渲染时不执行脚本,客户端在 hydration 之前已完成主题设置,避免了水合不匹配。
七、过渡动画:让主题切换更丝滑
主题切换不应是「啪」一下的跳变,而应该是平滑的过渡。通过在 body 和关键元素上添加 transition 属性,可以实现颜色变化的渐变效果:
body,
.bg-primary,
.bg-secondary,
.text-primary,
.border-default {
transition: background-color 250ms ease,
color 250ms ease,
border-color 250ms ease;
}
需要注意的是,过渡动画不应应用于所有元素,否则会造成性能问题。建议只对高频变化的属性(背景、文字、边框)且用户可见的元素启用过渡。
结语
Codex 官网的暗色主题之所以「好看」,不是因为它用了某个特定的色值,而是因为它建立了一套可扩展、可维护、可切换的 Design Token 体系。从 #0a0a0f 到 #10a37f,每一个颜色值背后都有明确的语义和层级归属。
通过 CSS 自定义属性实现 Token 化,通过 Tailwind 配置实现工程化,通过内联脚本实现无闪烁切换——这三层架构共同构成了现代前端主题系统的最佳实践。希望本文的代码模板能够直接应用于你的下一个项目,让你的产品也能拥有 Codex 级别的视觉一致性。
转载自:https://blog.csdn.net/sghtgjfhv/article/details/163981599
欢迎 👍点赞✍评论⭐收藏,欢迎指正
葡萄城是专业的软件开发技术和低代码平台提供商,聚焦软件开发技术,以“赋能开发者”为使命,致力于通过表格控件、低代码和BI等各类软件开发工具和服务,一站式满足开发者需求,帮助企业提升开发效率并创新开发模式。
更多推荐


所有评论(0)