design-system
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseThe 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 and
their , 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.
InputButtonSo: 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项目中,与他们的和共存,且必须视觉风格统一。如果一个场景组件自行定义灰色调,那么它与安装该组件的应用会产生视觉冲突。
InputButton**因此:任何组件都不得自行定义颜色、边框、阴影或圆角。**这些样式需来自设计系统,或来自已解决这些样式问题的原语。
Rule 1 — the tokens are the source of truth
规则1——Token是唯一可信源
registry/snap-cn-ui/core/theme.tsSnapCnThemeapp/globals.csscomponents/ui/*pnpm run check:tokens| token | light | what it is |
|---|---|---|
| | the page (warm off-white — not |
| | a surface on the page |
| | text (not |
| | secondary text, leading icons |
| | hairline |
| | focus, accent |
| | controls |
Dark is the block of the same file ( page, card,
hairline, the same ).
.dark#0a0a0b#141417#26272b#3577e0Never edit this table or on its own. A token changes in
first, because that is what the shadcn components obey;
follows, and proves it did. The one exception runs the other way:
is set so its step lands on this .
theme.tsglobals.csstheme.tscheck:tokens--radius: 0.28rem--radius-3xl10Resolve 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 defaultand take + as props, like
every other component does. That is what lets a user drop the component into their
own palette without forking it.
theme?: Partial<SnapCnTheme>mode?: "light" | "dark"registry/snap-cn-ui/core/theme.tsSnapCnThemeapp/globals.csscomponents/ui/*pnpm run check:tokens| Token | 浅色模式值 | 说明 |
|---|---|---|
| | 页面背景(暖调米白色——不是 |
| | 页面上的卡片表面 |
| | 文本颜色(不是 |
| | 次要文本、前置图标颜色 |
| | 细边框 |
| | 焦点、强调色 |
| | 控件圆角 |
深色模式对应同一文件中的块(页面背景,卡片背景,细边框,强调色仍为)。
.dark#0a0a0b#141417#26272b#3577e0**切勿单独修改此表格或文件。**Token的变更需先在中进行,因为shadcn组件遵循该文件中的样式;之后再同步修改,并通过命令验证一致性。唯一例外的是反向流程:设置是为了让其层级对应到这个值。
theme.tsglobals.csstheme.tscheck:tokens--radius: 0.28rem--radius-3xl10请通过Hook解析Token,切勿直接导入对象:
tsx
import { type SnapCnTheme, useSnapCnTheme } from "@/lib/snap-cn-ui";
const t = useSnapCnTheme(theme, mode); // 属性 > 提供者 > 浅/深色默认值并像其他组件一样,接收 + 作为属性。这样用户无需fork组件,就能将其集成到自己的调色板中。
theme?: Partial<SnapCnTheme>mode?: "light" | "dark"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, mutedForegroundNow the two cannot drift apart. Declare the primitive in
(, , ) so a user installing
your component gets it.
registryDependencies@snap-cn/input@snap-cn/caret@snap-cn/snap-cn-uiComposing the rendered primitive is a different question, and often the wrong
one — 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.
InputUI层已导出每个控件自身使用的样式上下文。如果一个输入框想要看起来和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组合已渲染的原语则是另一回事,且通常不是正确的做法——是一个带有状态机和切片式文本显示的320px控件。一个具有特定比例、裁剪显示和相机效果的核心输入框无法通过它构建。复用它的Token,而非直接复用它的容器。
InputRule 3 — shadows
规则3——阴影
This is where it went wrong, and the bug is worth keeping:
shipped asearch-typingdrop shadow at argba(9,9,12,0.45)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.0.12 × height
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
() and say which backdrop it is lit for.
surface="glass"这是曾经出错的地方,这个错误值得引以为戒:
组件使用了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.
#d9d9d91pxSo 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 mixmixOklchforeground取消阴影后,下一个问题是边框:*“边框太淡了。”*确实如此——但问题不在Token本身,而在于尺寸比例。
#d9d9d9因此需要调整尺寸,但必须通过系统进行缩放,而非自行定义灰色值:
tsx
const border = Math.max(1, 0.009 * H); // 边框宽度:与输入框高度的比例
const borderColor = mixOklch(ui.idleBorder, t.foreground, 0.28); // 对比度:使用系统自身的混合方法mixOklchforegroundRule 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 , and they have to be
legible over a face, a sky or a white desk. What governs them is craft, not tokens:
Input- A heavy outline, drawn OUTSIDE the letterform. centres the stroke and eats the glyph from the inside — measured, a 14px stroke takes a 38px stem down to 22px.
-webkit-text-strokedraws 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.paint-order: stroke fill - Display weight and display size. Montserrat 800–900 at 11–13% of the frame's
short side. shipped at Inter 700 and 2.8% of the height — a subtitle wearing a caption's name.
word-captions - 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)和标题是唯一不适用应用调色板的场景。它们是嵌入到视频素材中的,不会与用户的共存,且必须在人脸、天空或白色桌面等任何背景上都清晰可读。支配它们的是制作工艺,而非Token:
Input- 粗描边,绘制在字母轮廓外侧。会将描边居中,从内部侵蚀字形——实测显示,14px的描边会将38px的字干缩小到22px。
-webkit-text-stroke会先绘制描边,再填充字形,字干将恢复到38px。如果没有这一行代码,字幕会显得廉价,但没人能说出原因。paint-order: stroke fill - **展示级字重和字号。**使用Montserrat 800–900字重,字号为画面短边的11–13%。组件曾使用Inter 700字重,字号仅为高度的2.8%——这是挂着字幕名头的副标题。
word-captions - **强调色就是设计本身。**字幕的黄色不是品牌Token,而是视觉风格。将其作为属性暴露出来;切勿直接使用。
theme.primary
如果组件包含应用界面元素(如胶囊按钮、卡片),仍需从系统中获取中性色。例外情况是嵌入视频的文字本身。
Rule 4 — a token has to survive the renderer
规则4——Token必须能在渲染器中生效
Animated colours must be concrete // and interpolated with
. 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 in an inline ; anything you tween
may not.
oklchhexrgbmixOklchvar(--token)var()styleThe same trap catches fonts: reaching for gets you the face on
the site and a fallback in the render. Load the face with
so the Player, the mp4 and a user's own project all agree.
var(--font-outfit)@remotion/google-fonts动画颜色必须是具体的//值,并使用进行插值。无法被Remotion的无头渲染器解析用于JS插值——Remotion打包文件中不包含应用的CSS。静态、无动画的颜色可以在内联中使用;任何需要补间动画的颜色都不能使用。
oklchhexrgbmixOklchvar(--token)stylevar()字体也存在同样的陷阱:使用在网站上能正确显示字体,但在渲染时会回退到默认字体。请使用加载字体,确保播放器、mp4文件和用户自己的项目都能显示一致的字体。
var(--font-outfit)@remotion/google-fontsRule 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 — vs the default —
and say in its doc which backdrop it is lit for.
surface="glass"surface="shadcn"当组件基于参考设计建模时,几何尺寸值得精确到像素测量——圆角/高度、大写字母高度、描边、间距。这能让组件的质感与参考设计一致。
但参考设计的颜色属于其品牌,而非你的组件。Slack的紫色、Slack的玻璃质感灰到白渐变输入框、Slack的阴影:这些都不属于发布给第三方的组件库组件。提取形状,舍弃配色。
如果测量得到的表面样式确实值得保留,请将其作为设计系统默认样式之外的可选选项提供——比如与默认的——并在文档中说明它适配的背景是什么。
surface="glass"surface="shadcn"