Storybook 明暗主题一键切换实战:addon-themes 配合 PostCSS dark-theme-class 的完整落地方案
本文聚焦 Storybook 官方 @storybook/addon-themes 插件的 PostCSS 接入方案,对应仓库文档 postcss.md。如果你的项目使用 prefers-color-scheme 媒体查询来编写暗色样式,读完本文你将掌握:如何用 postcss-dark-theme-class 插件把媒体查询规则改写为类名选择器、如何在 .storybook/preview.js 中接入 withThemeByClassName 装饰器,以及主题切换工具在 Manager 与 Preview 两侧的完整实现原理——从类名切换逻辑到 channel 事件通信,全部有源码佐证。
方案总览:三方协作的明暗主题机制
PostCSS 方案的核心思路是把"跟随系统自动切换的暗色样式"改造成"跟随类名切换的暗色样式",由三方协作完成:
@storybook/addon-themes:提供 Manager 工具栏中的主题切换按钮,以及 Preview 侧的主题类名装饰器;postcss-dark-theme-class:一个 PostCSS 插件,负责把@media (prefers-color-scheme: dark)规则块的内容拷贝到.is-dark选择器下,使暗色样式脱离系统媒体查询、改由类名控制;withThemeByClassName装饰器:在渲染故事前,根据当前选中的主题,在父元素(默认是html)上增删is-light/is-dark类名。
这样浏览器中的故事就能通过类名命中暗色样式,而切换动作只需点击工具栏一次。
第一步:安装依赖
按官方文档 postcss.md 的说明,需要同时安装插件与 PostCSS 处理器两个包(作为 dev dependency):
yarn:
yarn add -D @storybook/addon-themes postcss-dark-theme-class
npm:
npm install -D @storybook/addon-themes postcss-dark-theme-class
pnpm:
pnpm add -D @storybook/addon-themes postcss-dark-theme-class
两个包各司其职:前者来自本仓库 code/addons/themes(当前仓库版本为 10.x,插件要求 Storybook 7.0 及以上,见 addons/themes 的 README);后者是外部 PostCSS 生态插件,仅参与样式编译阶段,不依赖 Storybook。
第二步:在 main 配置中注册 Addon
将插件加入 .storybook/main.js 的 addons 数组:
module.exports = {
stories: [
"../stories/**/*.stories.mdx",
"../stories/**/*.stories.@(js|jsx|ts|tsx)",
],
addons: [
"@storybook/addon-essentials",
+ "@storybook/addon-themes"
],
};
注册之后,插件的 manager 入口 会通过 addons.register 向 Manager 侧注册一个 type: types.TOOL 的工具,标题为 Themes,paramKey 为 themes。其中 match 条件限定了工具的显示时机:
match: ({ viewMode, tabId }) => !!(viewMode && viewMode.match(/^(story|docs)$/)) && !tabId,
也就是说,只有在 story 视图或 docs 视图(且未打开具体子标签页)时,工具栏中才会出现主题切换器——这与主题切换作用于"当前故事"的语义一致。
第三步:配置 postcss-dark-theme-class,把媒体查询改写为类名
CSS 为暗色模式提供了专门媒体 at-rule:@media (prefers-color-scheme: dark)。按原文档的说明,postcss-dark-theme-class 的作用就是把这些 at-rule 中的内容拷贝到 .is-dark 选择器下,从而让暗色样式可以由 .is-dark 类名激活,而不是依赖用户系统偏好。
配置前先检查项目中是否已有 PostCSS 配置,可能是以下三种位置之一:
- 项目根目录的
postcss.config.js; package.json中的"postcss"字段;- bundler 配置中内联的 postcss 配置。
然后在插件列表中追加:
module.exports = {
plugins: [
+ require('postcss-dark-theme-class'),
require('autoprefixer')
]
}
配置完成后,你的 CSS 可以照常用 prefers-color-scheme 媒体查询书写暗色样式:
:root {
--text-color: black;
}
@media (prefers-color-scheme: dark) {
html {
--text-color: white;
}
}
编译后,.is-dark 下会存在等价的规则块,暗色变量即可通过给 html 元素添加 is-dark 类来生效。
第四步:在 preview.js 中引入样式
为了让故事能访问到这些全局样式,需要把 CSS 导入 .storybook/preview.js:
import { Preview } from "@storybook/your-renderer";
+import "../src/index.css";
const preview: Preview = {
parameters: { /* ... */ },
};
export default preview;
第五步:用 withThemeByClassName 装饰器启用一键切换
最后,把 withThemeByClassName 装饰器加入 decorators,并声明"主题名 → 类名"的映射:
-import { Preview } from "@storybook/your-renderer";
+import { Preview, Renderer } from "@storybook/your-renderer";
+import { withThemeByClassName } from "@storybook/addon-themes";
import "../src/index.css";
const preview: Preview = {
parameters: { /* ... */ },
+ decorators: [
+ withThemeByClassName<Renderer>({
+ themes: {
+ light: "is-light",
+ dark: "is-dark",
+ },
+ defaultTheme: "light",
+ }),
+ ]
};
export default preview;
参数说明(与源码中 ClassNameStrategyConfiguration 接口 完全一致):
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
themes |
Record<string, string> |
是 | 主题名到父元素类名的映射,如 { light: "is-light", dark: "is-dark" };值也支持空格分隔的多个类名 |
defaultTheme |
string |
是 | 初始选中的主题名,必须是 themes 的键之一 |
parentSelector |
string |
否 | 类名挂载的父元素选择器,默认值为 'html'(见 源码常量) |
仓库中官方的示例故事 decorators.stories.ts 展示了自定义 parentSelector 的用法:当样式作用域不在 <html> 上时,可以把类名挂到 #storybook-root > * 上。
源码深潜:一次主题切换背后发生了什么
装饰器侧:类名的原子化切换
withThemeByClassName 的实现 分为两层。装饰器工厂函数执行时先调用 initializeThemeState(Object.keys(themes), defaultTheme)——这会通过 addons channel 发出 REGISTER_THEMES 事件,把主题清单和默认主题通知给 Manager(见 helpers.ts),Manager 侧的切换器正是靠这条事件获知"有哪些主题可切"。
每次故事渲染时,内部逻辑(第 30-52 行)按如下规则工作:
- 确定当前主题:优先级为
themeOverride(来自parameters.themes.themeOverride)> 全局theme(工具栏选择值)>defaultTheme; - 摘除旧类名:遍历所有非选中主题对应的类名,从父元素上
classList.remove; - 挂上新类名:把选中主题映射的类名
classList.add上去,classStringToArray支持空格分隔的多类名。
整个过程由 useEffect 驱动、依赖 [themeOverride, selected],因此切换是响应式的,且不会在每次渲染时重复增删。
Manager 侧:两个主题用按钮,多个主题用下拉框
工具栏中的切换器实现在 theme-switcher.tsx,它的形态由主题数量决定:
- 恰好 2 个主题(PostCSS 明暗方案正是这种场景):渲染一个
Button切换按钮,点击后直接updateGlobals({ theme: alternateTheme })切换到另一个主题(第 73-90 行); - 3 个及以上主题:渲染一个
Select下拉框,选项来自主题名列表(第 92-109 行); - 只有 1 个主题:不渲染任何 UI。
切换的本质是把 Manager 侧的全局变量 theme 更新掉,该全局变量在 preview.ts 中被初始化为空字符串。相关的键名常量集中在 constants.ts:参数键 themes、全局键 theme、Addon ID storybook/themes、事件名 storybook/themes/REGISTER_THEMES。
锁定与禁用机制
源码中有两个"锁定"入口,对应 types.ts 中声明的参数结构:
-
故事级覆盖:当故事通过
globals.theme或parameters.themes.themeOverride指定了主题时,isLocked为真(第 43 行),工具栏按钮变为禁用态并显示Story override提示——因为该故事的主题被参数接管了。README 给出的标准写法(meta 级与 story 级覆盖):export default { title: 'Example/Button', component: Button, // meta 级覆盖 globals: { theme: 'dark' }, }; export const PrimaryDark = { args: { primary: true, label: 'Button' }, // story 级覆盖 globals: { theme: 'dark' }, }; -
整体禁用:设置
parameters.themes.disable: true时,切换器直接返回null,工具面板消失且 addon 行为停止(第 69-71 行)。
常见场景适配
- 暗色样式挂在自定义元素而非
<html>:通过parentSelector参数调整挂载点,例如仓库示例中的parentSelector: '#storybook-root > *'; - 想要 data 属性而非类名:同一插件还提供
withThemeByDataAttribute装饰器(decorators 导出),用法与类名版对称; - 框架级主题方案(styled-components、emotion、MUI、Tailwind、Bootstrap):仓库提供了平行的 getting-started 配方文档,可参考 bootstrap.md、emotion.md、material-ui.md、styled-components.md、tailwind.md;
- 需要完全自定义的挂载逻辑:可以参照 api.md 中"编写自定义装饰器"一节自行实现;
- 想查装饰器更多细节:官方文档入口为 docs/essentials/themes.mdx,配套代码片段见 storybook-addon-themes-classname-decorator.md。
小结
PostCSS 方案的价值在于零侵入地复用现有暗色样式:你不需要为 Storybook 重写任何 CSS,只需让 postcss-dark-theme-class 把 prefers-color-scheme 规则"镜像"到 .is-dark,再用 withThemeByClassName 把 is-light / is-dark 类名按工具栏选择挂到父元素上。理解装饰器的"摘除旧类、挂载新类"逻辑、themeOverride > globals.theme > defaultTheme 的优先级,以及 Manager 与 Preview 之间通过 REGISTER_THEMES 事件的握手机制,就能把这套方案灵活适配到任意以 CSS 类控制主题的组件库中。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00