在这里插入图片描述

每日一句正能量

往上爬的时候要对别人好一点,因为你走下坡的时候会碰到他们。
在高处时释放的善意,是在为未来可能的低谷储备善意与帮助。真正的远见,包含对他人命运的共情,世界是一个圆,今日你如何对待他人,可能决定了明日你被如何对待。

摘要

打开 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-schemeprefers-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

  1. 内联脚本在 <head> 中最早执行:在浏览器解析 <body> 之前,主题已经通过 data-themeclass 应用到 <html> 上。
  2. CSS 变量即时生效theme.css 中定义的所有变量都基于 [data-theme] 选择器,主题确定后样式立即正确。
  3. 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
欢迎 👍点赞✍评论⭐收藏,欢迎指正

Logo

葡萄城是专业的软件开发技术和低代码平台提供商,聚焦软件开发技术,以“赋能开发者”为使命,致力于通过表格控件、低代码和BI等各类软件开发工具和服务,一站式满足开发者需求,帮助企业提升开发效率并创新开发模式。

更多推荐