design-system について
snapcn レジストリコンポーネントの開発者向けデザインシステムガイドです。shadcn のトークンやプリミティブのスタイルを再利用し、テーマ、レンダリング、参照デザインの整合性を保つ方法を説明します。
主な機能
- 01テーマトークンとサイトスタイルの同期
- 02プリミティブのスタイルコンテキスト再利用
- 03比率に応じたボーダー調整
- 04映像テキストと字幕のスタイル規則
- 05Remotion レンダラーへの対応
活用シーン
- 01レジストリコンポーネントのテーマ上書き対応
- 02コンポーネントとサイトの見た目の統一
- 03アニメーション色とフォントのレンダリング対応
- 04参照デザインに基づく比率の再現
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ shadcn-labs/ snapcn --skill design-systemインストール後、Claude Code で/design-systemを入力して読み込まれたか確認します。
詳細
デザインシステム
snapcn は shadcn のレジストリです。これはブランディングではなく、制約です。提供するコンポーネントはすべて、誰かの shadcn プロジェクトにインストールされ、そのプロジェクトの Input や Button の隣に置かれます。そこに自然になじむ見た目でなければなりません。独自のグレーで描画するシーンコンポーネントは、インストール先のアプリと衝突してしまいます。
つまり、コンポーネントが色、境界線、影、角丸を独自に決めてはいけません。 デザインシステムから取得するか、すでに解決済みのプリミティブから取得します。
ルール 1 — トークンを信頼できる唯一の情報源にする
registry/snap-cn-ui/core/theme.ts は shadcn のトークンセット(SnapCnTheme)であり、app/globals.css のミラーです。つまり、サイトの components/ui/* が描画に使用するものと同じ値です。これは手作業で保つ必要のある偶然の一致ではありません。2 つの値が食い違うと、pnpm run check:tokens が失敗します。この仕組みが存在するのは、実際に値が食い違っていた時期が長く続き、サイトは暖色系なのに、販売している動画は寒色系だったからです。
| トークン | ライト | 意味 |
|---|---|---|
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 だけを編集してはいけません。 トークンを変更するときは、まず globals.css を変更します。shadcn コンポーネントが従うのはそこだからです。次に theme.ts を更新し、変更が反映されていることを check:tokens で確認します。唯一の例外は逆方向で、--radius: 0.28rem を設定し、その --radius-3xl の値がこの 10 になるようにしています。
オブジェクトをインポートせず、フックで解決してください。
import { type SnapCnTheme, useSnapCnTheme } from "@/lib/snap-cn-ui";
const t = useSnapCnTheme(theme, mode); // prop > provider > light/dark default
また、ほかのコンポーネントと同様に、theme?: Partial<SnapCnTheme> と mode?: "light" | "dark" をプロパティとして受け取ります。これにより、ユーザーはコンポーネントをフォークせずに、自分のパレットへ組み込めます。
ルール 2 — サーフェスを再定義せず、プリミティブのものを再利用する
UI 層は、各コントロールが自身の描画に使うスタイルコンテキストをすでにエクスポートしています。shadcn の入力欄のような見た目にしたいフィールドは、そのサーフェスを shadcn の入力欄から取得してください。
import { inputStyleContext } from "@/components/snap-cn/input";
const ui = inputStyleContext(t);
// → idleBorder, hoverBorder, activeBorder, ring, background, foreground, mutedForeground
これで、2 つのスタイルが食い違うことはありません。registryDependencies(@snapcn/input、@snapcn/caret、@snapcn/snap-cn-ui)でプリミティブを宣言し、コンポーネントをインストールするユーザーがそれを取得できるようにします。
レンダリング済みのプリミティブを組み合わせるかどうかは別の問題であり、多くの場合、適切ではありません。Input は状態マシンとスライス単位のテキスト表示を備えた 320px のコントロールです。寸法を調整した比率、クリップされた表示、カメラを備えるヒーローフィールドは、これを使って構築できません。トークンを使い、ボックスは使わないでください。
ルール 3 — 影
ここで間違いが起きました。このバグは覚えておく価値があります。
search-typingでは、0.12 × heightのぼかしをかけたrgba(9,9,12,0.45)のドロップシャドウが採用されました。参考動画がそうなっていたからです。参考動画の背景は暗い紫色で、濃い影が奥行きとして見えていました。同じコンポーネントを白いページに置くと、影はコントロールの下に灰色のにじみとして見えます。安っぽく見え、誰もが最初に気づく点でした。
「よく見ないと気づかない影」に弱めても、まだ間違いでした。次のフィードバックでも、同じ言葉が返ってきました。最悪。このサイズのフィールドの下に見えるほど大きなドロップシャドウは、明るいページではどう見ても灰色のにじみです。
デフォルトでは、ドロップシャドウを一切使いません。 shadcn では、細い境界線を持つコントロールが定義されており、境界線だけですべての役割を果たします。
background: t.card, // #ffffff
border: `1px solid ${ui.idleBorder}`, // #d9d9d9
boxShadow: "none",
グラデーションの塗りつぶしも、見た目を変えただけで同じ間違いです。参考画像のグレーから白へのフィールドは、明るいページでは内側の影のように見えます。塗りつぶしはフラットにし、境界線は細くし、それ以外は何も加えません。
参考画像から持ち込んだ影は、その参考画像の背景に合わせて調整されたものです。コンポーネントの性質ではありません。光の当たる表面が必要なら、オプトイン(surface="glass")として用意し、どの背景に合わせて調整されているかを明記してください。
ルール 3b — トークンは、自分の尺度ではなくコントロールの尺度で指定する
影をなくすと、次は境界線が指摘されました。「境界線がとても薄い」。そのとおりでした。ただし、間違っていたのはトークンではなく、尺度でした。
高さ 40px のコントロールでは、1px の #d9d9d9 は細い境界線です。高さに対して 2.5% です。同じ境界線を高さ 190px のヒーローフィールドに使うと、高さに対して 0.5% になり、ほとんど見えなくなります。トークンの重みは、縁取る対象に対して相対的に決まります。
そのため、尺度を調整します。ただし、グレーを新たに作るのではなく、システムを通じて調整します。
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 は、システムの色空間で細い境界線の色を foreground に近づけます。そのため、結果はパレットに沿い、ユーザーのテーマの上書きにも追従します。トークンが「薄すぎるように見えた」からといって、安易に 16 進数カラーを使わないでください。
ルール 3c — 動画に焼き込まれた文字は、アプリのクロームではない
キャプション、ローワーサード、タイトルは、アプリのパレットが適用されない唯一の場所です。映像に焼き込まれ、誰の Input の隣にも配置されず、顔や空、白いデスクの上でも読みやすくなければなりません。ここで基準となるのはトークンではなく、作り込みです。
- 文字の外側に描く、太い縁取り。
-webkit-text-strokeは縁取りを中央に配置するため、グリフの内側が削られます。計測では、14px の縁取りによって 38px のステムが 22px になります。paint-order: stroke fillを使えば、先に縁取りを描き、その上に塗りを重ねるため、ステムは 38px に戻ります。この一行がないと、キャプションは安っぽく見えますが、その理由を説明できる人はいません。 - ディスプレイ用の太さとサイズ。 フレームの短辺に対して 11〜13% のサイズで、Montserrat 800〜900 を使います。
word-captionsでは Inter 700、かつ高さの 2.8% で出荷されていました。キャプションと名乗る字幕でした。 - アクセントがデザインそのものです。 キャプションの黄色はブランドトークンではなく、見た目を決める要素です。プロパティとして公開し、
theme.primaryを使わないでください。
コンポーネントにアプリのクローム(ピルやカード)がある場合は、ニュートラルカラーを引き続きシステムから使用してください。例外は、映像に焼き込まれた文字そのものです。
ルール 4 — トークンはレンダラーに対応していなければならない
アニメーションする色は、具体的な oklch/hex/rgb で指定し、mixOklch で補間する必要があります。JS での補間では、Remotion のヘッドレスレンダラーは var(--token) を解決できません。Remotion のバンドルには、アプリの CSS が含まれていないためです。アニメーションしない静的な色であれば、インラインの style 内で var() を使用できますが、アニメーションさせる色には使用できません。
フォントも同じ落とし穴があります。var(--font-outfit) を使うと、サイトではそのフォントが表示されますが、レンダリングではフォールバックフォントになります。@remotion/google-fonts でフォントを読み込めば、Player、mp4、ユーザー自身のプロジェクトですべて同じフォントになります。
ルール 5 — 参照元は色使いではなくプロポーションを測るために使います
コンポーネントを参照元に基づいて作る場合、ジオメトリはピクセル単位で測る価値があります。角の丸みや高さ、キャップハイト、線幅、間隔などです。そうすることで、そのものらしく感じられるようになります。
色は参照元のブランドのものであり、あなたのものではありません。 Slack の紫色や、Slack のガラスのようなグレーから白へのグラデーション、Slack の影は、どれも見知らぬ人々に提供するレジストリコンポーネントにふさわしくありません。形を取り入れ、色は残しておきましょう。
測定したサーフェスを本当に残す価値がある場合は、デザインシステムのデフォルトと並べてオプションとして提供します。たとえば、デフォルトの surface="shadcn" と surface="glass" のようにします。また、どの背景に合わせているかをドキュメントに記載しましょう。