design-system

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

The design system

设计系统

snapcn is a shadcn registry. That is not branding — it is a constraint. Every component we ship lands in somebody's shadcn project, next to their
Input
and their
Button
, and it has to look like it belongs there. A scene component that paints its own greys is a scene component that will clash with the app that installs it.
So: no component invents a colour, a border, a shadow or a radius. They come from the design system, or they come from a primitive that already solved it.

snapcn是一个shadcn组件库(registry)。这并非品牌标识——而是一项约束。我们发布的每个组件都会被集成到用户的shadcn项目中,与他们的
Input
Button
共存,且必须视觉风格统一。如果一个场景组件自行定义灰色调,那么它与安装该组件的应用会产生视觉冲突。
**因此:任何组件都不得自行定义颜色、边框、阴影或圆角。**这些样式需来自设计系统,或来自已解决这些样式问题的原语。

Rule 1 — the tokens are the source of truth

规则1——Token是唯一可信源

registry/snap-cn-ui/core/theme.ts
is a shadcn token set (
SnapCnTheme
), and it is a mirror of
app/globals.css
— the same values the site's
components/ui/*
paint from. That is not a coincidence to be maintained by hand:
pnpm run check:tokens
fails the moment the two disagree. It exists because they did disagree, for long enough that the site was warm and the videos it sells were cool.
tokenlightwhat it is
background
#faf9f6
the page (warm off-white — not
#fff
)
card
#ffffff
a surface on the page
foreground
#141414
text (not
#000
)
mutedForeground
#6e6a63
secondary text, leading icons
border
/
input
#d9d9d9
hairline
ring
/
primary
#3577e0
focus, accent
radius
10
controls
Dark is the
.dark
block of the same file (
#0a0a0b
page,
#141417
card,
#26272b
hairline, the same
#3577e0
).
Never edit this table or
theme.ts
on its own.
A token changes in
globals.css
first, because that is what the shadcn components obey;
theme.ts
follows, and
check:tokens
proves it did. The one exception runs the other way:
--radius: 0.28rem
is set so its
--radius-3xl
step lands on this
10
.
Resolve them with the hook, never by importing the object:
tsx
import { type SnapCnTheme, useSnapCnTheme } from "@/lib/snap-cn-ui";

const t = useSnapCnTheme(theme, mode);   // prop > provider > light/dark default
and take
theme?: Partial<SnapCnTheme>
+
mode?: "light" | "dark"
as props, like every other component does. That is what lets a user drop the component into their own palette without forking it.
registry/snap-cn-ui/core/theme.ts
是一套shadcn Token集合(
SnapCnTheme
),它是
app/globals.css
镜像文件——与网站
components/ui/*
中使用的样式值完全一致。这并非巧合,也无需手动维护:一旦两者不一致,
pnpm run check:tokens
命令就会执行失败。之所以这样设计,是因为过去两者曾长期不一致,导致网站显示偏暖色调,而其售卖的视频却偏冷色调。
Token浅色模式值说明
background
#faf9f6
页面背景(暖调米白色——不是
#fff
card
#ffffff
页面上的卡片表面
foreground
#141414
文本颜色(不是
#000
mutedForeground
#6e6a63
次要文本、前置图标颜色
border
/
input
#d9d9d9
细边框
ring
/
primary
#3577e0
焦点、强调色
radius
10
控件圆角
深色模式对应同一文件中的
.dark
块(页面背景
#0a0a0b
,卡片背景
#141417
,细边框
#26272b
,强调色仍为
#3577e0
)。
**切勿单独修改此表格或
theme.ts
文件。**Token的变更需先在
globals.css
中进行,因为shadcn组件遵循该文件中的样式;之后再同步修改
theme.ts
,并通过
check:tokens
命令验证一致性。唯一例外的是反向流程:设置
--radius: 0.28rem
是为了让其
--radius-3xl
层级对应到
10
这个值。
请通过Hook解析Token,切勿直接导入对象:
tsx
import { type SnapCnTheme, useSnapCnTheme } from "@/lib/snap-cn-ui";

const t = useSnapCnTheme(theme, mode);   // 属性 > 提供者 > 浅/深色默认值
并像其他组件一样,接收
theme?: Partial<SnapCnTheme>
+
mode?: "light" | "dark"
作为属性。这样用户无需fork组件,就能将其集成到自己的调色板中。

Rule 2 — reuse a primitive's surface, don't re-derive it

规则2——复用原语的表面样式,勿自行推导

The UI tier already exports the style context each control paints itself from. A field that wants to look like a shadcn input should get its surface from the shadcn input:
tsx
import { inputStyleContext } from "@/components/snap-cn/input";

const ui = inputStyleContext(t);
// → idleBorder, hoverBorder, activeBorder, ring, background, foreground, mutedForeground
Now the two cannot drift apart. Declare the primitive in
registryDependencies
(
@snap-cn/input
,
@snap-cn/caret
,
@snap-cn/snap-cn-ui
) so a user installing your component gets it.
Composing the rendered primitive is a different question, and often the wrong one —
Input
is a 320px control with a state machine and a slice-based text reveal. A hero field with measured proportions, a clipped reveal and a camera cannot be built out of it. Take its tokens; don't take its box.
UI层已导出每个控件自身使用的样式上下文。如果一个输入框想要看起来和shadcn的Input一致,应从shadcn的Input中获取其表面样式:
tsx
import { inputStyleContext } from "@/components/snap-cn/input";

const ui = inputStyleContext(t);
// → idleBorder, hoverBorder, activeBorder, ring, background, foreground, mutedForeground
这样两者的样式就不会出现偏差。请在
registryDependencies
中声明该原语(
@snap-cn/input
@snap-cn/caret
@snap-cn/snap-cn-ui
),以便用户安装你的组件时能自动获取依赖。
组合已渲染的原语则是另一回事,且通常不是正确的做法——
Input
是一个带有状态机和切片式文本显示的320px控件。一个具有特定比例、裁剪显示和相机效果的核心输入框无法通过它构建。复用它的Token,而非直接复用它的容器。

Rule 3 — shadows

规则3——阴影

This is where it went wrong, and the bug is worth keeping:
search-typing
shipped a
rgba(9,9,12,0.45)
drop shadow at a
0.12 × height
blur, because that is what the reference video had. The reference sat on a dark violet field, where a heavy shadow reads as depth. Put the same component on a white page and the shadow reads as a grey smear under the control. It looked cheap, and it was the first thing anyone noticed.
Softening it to "a shadow you have to look for" was still wrong — the next round of feedback was the same word: worst. A drop shadow big enough to be seen under a field that size is a grey smear on a light page, full stop.
The default is no drop shadow at all. shadcn defines a control with a hairline border, and the border does the whole job:
tsx
background: t.card,                          // #ffffff
border: `1px solid ${ui.idleBorder}`,        // #d9d9d9
boxShadow: "none",
A gradient fill is the same mistake wearing a hat: the reference's grey-crown-to- white field reads as an inner shadow on a light page. Flat fill, hairline border, nothing else.
A shadow lifted off a reference is lit for that reference's backdrop. It is not a property of the component. If you want the lit surface, ship it as an opt-in (
surface="glass"
) and say which backdrop it is lit for.
这是曾经出错的地方,这个错误值得引以为戒:
search-typing
组件使用了
rgba(9,9,12,0.45)
的投影,模糊半径为
0.12 × height
,因为参考视频中就是这样的效果。参考视频的背景是深紫色,厚重的阴影能体现出层次感。但将同一个组件放在白色页面上时,阴影会被视为控件下方的灰色污迹。看起来很廉价,而且是所有人最先注意到的问题。
将阴影调淡为“需要仔细看才能发现”的程度仍然不对——下一轮反馈还是同一个词:最差。对于这种尺寸的输入框,大到能被看到的投影在浅色页面上必然是灰色污迹,这是无法改变的事实。
**默认情况下完全不使用投影。**shadcn定义的控件带有细边框,边框已经能完成所有视觉区分工作:
tsx
background: t.card,                          // #ffffff
border: `1px solid ${ui.idleBorder}`,        // #d9d9d9
boxShadow: "none",
渐变填充也是换了种形式的错误:参考视频中的灰顶白底输入框在浅色页面上会被视为内阴影。应使用纯色填充、细边框,无其他样式。
从参考设计中提取的阴影是针对该参考设计的背景打光的,并非组件本身的属性。如果你想要这种带光影的表面样式,请将其作为可选参数(
surface="glass"
)提供,并说明它适配的背景是什么。

Rule 3b — a token is specified at a control's scale, not at yours

规则3b——Token是针对控件自身尺寸定义的,而非你的组件尺寸

Having killed the shadow, the border was next: "border is very light." It was — and the token was not wrong, the scale was.
#d9d9d9
at
1px
is a hairline on a 40px control: 2.5% of its height. Put the identical border on a 190px hero field and it is 0.5% of its height, and it all but vanishes. A token carries a weight relative to the thing it edges.
So scale it, but scale it through the system, not by inventing a grey:
tsx
const border = Math.max(1, 0.009 * H);                        // width: a ratio of the field
const borderColor = mixOklch(ui.idleBorder, t.foreground, 0.28);  // contrast: the system's own mix
mixOklch
walks the hairline toward
foreground
in the system's colour space, so the result still belongs to the palette and still follows a user's theme override. Never reach for a hex because the token "looked too light".
取消阴影后,下一个问题是边框:*“边框太淡了。”*确实如此——但问题不在Token本身,而在于尺寸比例
#d9d9d9
的1px边框在40px高的控件上是细边框:占其高度的2.5%。将完全相同的边框放在190px高的核心输入框上时,仅占其高度的0.5%,几乎看不见。Token的权重是相对于它所勾勒的元素的。
因此需要调整尺寸,但必须通过系统进行缩放,而非自行定义灰色值:
tsx
const border = Math.max(1, 0.009 * H);                        // 边框宽度:与输入框高度的比例
const borderColor = mixOklch(ui.idleBorder, t.foreground, 0.28);  // 对比度:使用系统自身的混合方法
mixOklch
在系统的色彩空间中将细边框颜色向
foreground
过渡,因此结果仍属于调色板,且会遵循用户的主题覆盖。切勿因为Token“看起来太淡”就直接使用十六进制色值。

Rule 3c — burned-in video type is not app chrome

规则3c——嵌入视频的文字不属于应用界面元素

Captions, lower thirds and titles are the one place the app palette does NOT apply. They are burned into footage, they sit next to nobody's
Input
, and they have to be legible over a face, a sky or a white desk. What governs them is craft, not tokens:
  • A heavy outline, drawn OUTSIDE the letterform.
    -webkit-text-stroke
    centres the stroke and eats the glyph from the inside — measured, a 14px stroke takes a 38px stem down to 22px.
    paint-order: stroke fill
    draws the stroke first and the fill over it, and the stem comes back to 38px. Without that one line, captions look cheap and nobody can say why.
  • Display weight and display size. Montserrat 800–900 at 11–13% of the frame's short side.
    word-captions
    shipped at Inter 700 and 2.8% of the height — a subtitle wearing a caption's name.
  • The accent is the design. A caption's yellow is not a brand token, it is the look. Expose it as a prop; do not reach for
    theme.primary
    .
Still take the neutrals from the system where a component has app chrome (a pill, a card). The exception is the burned-in type itself.
字幕、下三分之一标题(lower thirds)和标题是唯一不适用应用调色板的场景。它们是嵌入到视频素材中的,不会与用户的
Input
共存,且必须在人脸、天空或白色桌面等任何背景上都清晰可读。支配它们的是制作工艺,而非Token:
  • 粗描边,绘制在字母轮廓外侧。
    -webkit-text-stroke
    会将描边居中,从内部侵蚀字形——实测显示,14px的描边会将38px的字干缩小到22px
    paint-order: stroke fill
    会先绘制描边,再填充字形,字干将恢复到38px。如果没有这一行代码,字幕会显得廉价,但没人能说出原因。
  • **展示级字重和字号。**使用Montserrat 800–900字重,字号为画面短边的11–13%。
    word-captions
    组件曾使用Inter 700字重,字号仅为高度的2.8%——这是挂着字幕名头的副标题。
  • **强调色就是设计本身。**字幕的黄色不是品牌Token,而是视觉风格。将其作为属性暴露出来;切勿直接使用
    theme.primary
如果组件包含应用界面元素(如胶囊按钮、卡片),仍需从系统中获取中性色。例外情况是嵌入视频的文字本身。

Rule 4 — a token has to survive the renderer

规则4——Token必须能在渲染器中生效

Animated colours must be concrete
oklch
/
hex
/
rgb
and interpolated with
mixOklch
.
var(--token)
cannot be resolved by Remotion's headless renderer
for JS interpolation — a Remotion bundle has none of the app's CSS. Static, never-animated colours may use
var()
in an inline
style
; anything you tween may not.
The same trap catches fonts: reaching for
var(--font-outfit)
gets you the face on the site and a fallback in the render. Load the face with
@remotion/google-fonts
so the Player, the mp4 and a user's own project all agree.
动画颜色必须是具体的
oklch
/
hex
/
rgb
值,并使用
mixOklch
进行插值。
var(--token)
无法被Remotion的无头渲染器解析
用于JS插值——Remotion打包文件中不包含应用的CSS。静态、无动画的颜色可以在内联
style
中使用
var()
;任何需要补间动画的颜色都不能使用。
字体也存在同样的陷阱:使用
var(--font-outfit)
在网站上能正确显示字体,但在渲染时会回退到默认字体。请使用
@remotion/google-fonts
加载字体,确保播放器、mp4文件和用户自己的项目都能显示一致的字体。

Rule 5 — measure the reference for proportion, not for palette

规则5——参考设计需测量比例,而非照搬调色板

When a component is modelled on a reference, the geometry is worth measuring to the pixel — radius/height, cap height, stroke, spacing. That is what makes it feel like the thing.
Its colours are the reference's brand, not yours. Slack's violet, Slack's glassy grey-to-white field, Slack's shadow: none of that belongs in a registry component that ships to strangers. Take the shape. Leave the paint.
If a measured surface is genuinely worth keeping, ship it as an option next to the design-system default —
surface="glass"
vs the default
surface="shadcn"
— and say in its doc which backdrop it is lit for.
当组件基于参考设计建模时,几何尺寸值得精确到像素测量——圆角/高度、大写字母高度、描边、间距。这能让组件的质感与参考设计一致。
但参考设计的颜色属于其品牌,而非你的组件。Slack的紫色、Slack的玻璃质感灰到白渐变输入框、Slack的阴影:这些都不属于发布给第三方的组件库组件。提取形状,舍弃配色。
如果测量得到的表面样式确实值得保留,请将其作为设计系统默认样式之外的可选选项提供——比如
surface="glass"
与默认的
surface="shadcn"
——并在文档中说明它适配的背景是什么。