可切换主题的 React 组件库:所有主题共用一套组件结构和 API,换主题只换 CSS,不改组件结构,以源码形式通过 shadcn registry 分发。
- 术语:GLOSSARY.md
- 架构决策:docs/adr/
- registry 命名空间:
@facet,托管在 GitHub,安装方式见「使用」 - 协议:MIT
- 技术栈:Vite、React 19、Tailwind v4、TypeScript、
@base-ui/react(经由 shadcn 的 Base UI 版组件)
registry 托管在 GitHub 仓库 SuperJony/facet,有两种装法。
-
在使用方项目的
components.json里登记@facet,指向仓库里shadcn build产出的public/r/:{ "registries": { "@facet": "https://raw.githubusercontent.com/SuperJony/facet/main/public/r/{name}.json" } }不登记就安装
@facet/...会报Unknown registry "@facet"。本地开发时,bun run dev会在http://localhost:5173/r/{name}.json提供同样的文件;改了组件或主题后,先跑bun run registry:build,这里才是新内容。 -
安装:
npx shadcn add @facet/button --overwrite。它会一起装上基线主题(@facet/theme-base)。拟物主题另装:npx shadcn add @facet/theme-skeuo。facet 的 Button 会替换项目里原有的
ui/button.tsx(shadcn 初始化时常已装好),所以要加--overwrite。不加的话,CLI 会问The file button.tsx already exists. Would you like to overwrite? (y/N):选 N,或在非交互环境里运行(命令照样以 0 退出),结果是themes/base.css已经写入,ui/button.tsx仍是原来的 shadcn 版本,没有任何cn-*类名和装饰槽,主题对它不起作用。补救办法是加上--overwrite再装一次。也可以不用命名空间,直接按 GitHub 地址装:
npx shadcn add SuperJony/facet/theme-skeuo。CLI 读取仓库根目录的registry.json和其中登记的源文件,不读public/r/;默认用默认分支,用#<分支、tag 或 commit>可以指定版本。button依赖@facet/theme-base,所以用这种方式装SuperJony/facet/button时,同样要先完成第 1 步的登记。 -
启用主题。每个主题都要做两步,缺一步按钮就没有外观(背景透明、无圆角):
- 在含
@import "tailwindcss"的入口 CSS 里@import主题文件。主题文件装在src/themes/,项目没有src目录时装在根目录的themes/。 - 在祖先元素(比如
<html>)上设置data-theme="base"或data-theme="skeuo"。
- 在含
- 只支持 Base UI 版的 shadcn 项目(
components.json的style是base-*)。@facet/button会覆盖ui/button.tsx。在 Radix 版项目里,原有组件(如 alert-dialog、pagination)给 Button 传的asChild会报类型错误。 - 暗色跟随使用方的
dark变体(shadcn 默认是祖先元素上的.dark),两个主题判定方式相同。
-
浏览器:最低 Chrome/Edge 122、Firefox 128、Safari 18(用到 OKLCH 相对颜色、
@property和color-mix)。更旧的浏览器里:- 不支持 OKLCH 相对颜色时,default 和 secondary 算不出按键面渐变:显示纯色的
--primary、--secondary,default 的描边换成color-mix调出的深色,悬停和按下时这两种按键面不变色。destructive、outline、ghost 只用到color-mix,外观不变。 - Chrome 119–121 能解析相对颜色,但把
l当成 0–100 来算(MDN 兼容数据,crbug 40940488)。@supports只检查语法,查不出这个问题,所以这几个版本里 default 的按键面是 L 约 0.196 的深灰,悬停时反而变暗;文字仍然看得清。
- 不支持 OKLCH 相对颜色时,default 和 secondary 算不出按键面渐变:显示纯色的
-
default 不完全等于
--primary:亮色下按键面的 OKLCH 亮度至少是 0.371(Tailwind 的 neutral-700),否则接近黑色的 primary 上看不出明暗渐变。 -
换颜色要改变量,不能用
bg-*:这一点和 shadcn 原版、基线主题都不同。拟物的按键面是从颜色变量算出来的渐变(background-image),bg-<颜色>只改 background-color,被渐变盖住,看不出变化(ghost 和 link 没有按键面,destructive 的按键面半透明,这三种会透出bg-*的颜色)。要换颜色,改按键颜色的来源:default 改--primary和--primary-foreground;destructive 的按键面是半透明的--destructive,文字也是--destructive,所以只改--destructive。只改一个按钮:<Button className="[--primary:var(--color-blue-600)] [--primary-foreground:white]">保存</Button> <Button variant="destructive" className="[--destructive:var(--color-orange-600)]">移除</Button>
-
其他工具类:
shadow-*、ring-*、inset-shadow-*会替换整条 box-shadow,五层材质阴影都会消失;焦点环是 outline,不受影响。bg-none、bg-linear-*会去掉按键面。 -
焦点环是 outline:使用方的
outline-none、outline-hidden,或者全局的:focus { outline: none }重置,会把拟物的焦点环去掉。基线主题的焦点环是 box-shadow,不受这些影响。 -
材质参数(
--mat-*):在任一祖先元素上设置即可覆盖默认值;大多数只影响 default。px 参数必须带px,百分比参数写纯数字(9 表示 9%)。写错单位不会报错,只在用到这个参数的 variant 和状态下出问题,比如整条 box-shadow 失效、五层材质阴影一起消失,或者渐变变成纯色。每个参数的单位、影响的 variant,以及写错单位的具体后果,见src/themes/skeuo.css的文件头。 -
**展示站的"复制 CSS"**只导出改过的参数,写在拟物主题的按钮上。亮色改过的参数在暗色那一份里也会带上暗色的值;只在暗色改过的参数只写进暗色那一份。暗色那一份和主题一样,按按钮祖先元素上的
.dark类判断;如果项目用媒体查询或别的选择器切换暗色,要自己把选择器换掉。
- 里程碑 1:Button(已完成)。基线主题和拟物主题,覆盖 6 个 variant 和 8 个 size;
link在拟物主题下只当文字链接处理,不做材质。展示站第一版已上线(见「展示站」)。 - 里程碑 2:Popover(下一步)。验证"主题 CSS + Base UI 状态属性 + 进出场动画"这条链路,细节到时再定。
- 玻璃主题。前提是拟物主题完成,现已满足;细节到时再定。
在线预览:https://superjony.github.io/facet/。每次推送到 main,由 .github/workflows/pages.yml 构建并发布到 GitHub Pages。
- 屏幕宽度:只适配桌面端,最小宽度 1024px(Tailwind
lg断点),不做移动端布局;窄于 1024px 时页面横向滚动。 - 页面结构:左边组件列表,中间舞台,右边面板。顶栏左侧是库名和版本,右侧是主题切换(base / skeuo)和亮暗切换。
- 舞台:有单个和矩阵两种视图。矩阵的两个轴从 variant、size、disabled 中选;选了另一个轴正在用的 prop,两个轴会互换;表格左上角是对角斜分的表头格。舞台底部左侧是代码行,右侧是预览状态下拉(rest / hover / pressed / focus),单个和矩阵两种视图都跟着它变。
- 右侧面板:分 Props 和参数两个 tab。参数 tab 里每个材质参数一组滑块加数字输入,提供"重置"和"复制 CSS"两个按钮;当前是基线主题时,提示"基线主题不读取这些参数"。
bun install
bun run dev # 展示站
bun run build # shadcn build(产出 public/r/*.json)+ 类型检查 + 展示站构建
bun run registry:build # 只跑 shadcn build
bun run test:visual # 截图回归:矩阵 2 主题 × 亮暗 × 4 预览状态,外加 disabled、单个视图、减弱动效、强制颜色模式、触屏、aria-invalid / aria-expanded 等;另有材质参数默认值的一致性检查- 交付物:
src/components/ui/button.tsx、src/themes/base.css、src/themes/skeuo.css,在registry.json里登记。public/r/是@facet命名空间读取的文件,随仓库提交:改了组件、主题或registry.json后,要先跑shadcn build(bun run registry:build)再提交。展示站自用的 shadcn 组件装在src/showcase/ui/,不进 registry。 - registry 里的文件都用
registry:file类型。shadcn CLI 安装registry:ui等类型时会删掉源码里所有cn-*类名(@shadcn/registry的transformCleanup),主题就挂不上去了;只有registry:file/registry:item原样写入。 - 尺寸(高度、内边距、间距、字号、图标大小、1px 边框宽度)写在组件里,所有主题外框相同,切换主题布局不动;颜色、圆角、焦点环、disabled、过渡写在主题 CSS 里。主题 CSS 里有
@apply和@variant,所以要从含@import "tailwindcss"的入口 CSS 引入(见「使用」)。 - 预览状态由
postcss.config.js里的postcss-pseudo-classes在展示站构建时生成(ADR 0009)。展示站因此用@tailwindcss/postcss而不是@tailwindcss/vite,字体在src/main.tsx里引入(@tailwindcss/postcss内联 CSS import 时不改写url())。 - 截图基线在
tests/__screenshots__/,是在 macOS 的 Chromium 上录的;换平台要重新录:bunx playwright test --update-snapshots。比较阈值是 0,任何像素颜色变化都算失败。