component-family-consistency について
製品内のインタラクティブなコンポーネントの視覚的一貫性を保つスキルです。デザイナーや開発者が既存部品を再利用・汎用化し、寸法、トークン、境界線、状態を揃える方法を示します。
主な機能
- 01追加前に既存コンポーネントを監査
- 02共通デザイントークンの定義
- 03コントロールの高さと間隔の統一
- 04境界線とインタラクション状態の標準化
- 05アイコンごとの配置ずれの測定
活用シーン
- 01重複するボタンやコントロールの整理
- 02一貫性のあるフォームや部品ライブラリの構築
- 03製品画面間の視覚的なずれの監査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dembrandt/ dembrandt-skills --skill component-family-consistencyインストール後、Claude Code で/component-family-consistencyを入力して読み込まれたか確認します。
詳細
コンポーネントファミリーの一貫性
製品内のすべてのインタラクティブコンポーネント(ボタン、入力欄、セレクト、チェックボックス、ラジオボタン、ピル、バッジ、タグ、カレンダー、日付ピッカー、スライダー、トグル)は、同じビジュアルファミリーに属します。共通のデザインDNAを共有しています。ユーザーがどのコンポーネントを見ても、ほかのすべてのコンポーネントと同じ製品の一部だと感じられる必要があります。
共通のトークンを使わずにコンポーネントを個別に設計すると、製品は一体として作られたものではなく、部品を寄せ集めたもののように感じられます。
新しいコンポーネントを作る前に再利用する
コンポーネントを作る前に、既存のものを確認してください。ゼロから作るコンポーネントは、世話をする対象がもう一つ増えるということです。ファミリー内で一貫性(角丸、高さ、状態、動き)を保つ項目が増え、メンテナンスするものも増えます。新しく作るのは最終手段であり、最初の選択肢ではありません。次の順に検討してください。
- すでに同じ役割のコンポーネントがありますか? そのまま使ってください。ほぼ要件に合うなら、複製するのではなく、プロパティやバリアントで拡張してください。並行して存在する
PrimaryButton、BigButton、CtaButtonよりも、柔軟なButtonひとつのほうが優れています。 - コードベースに、汎用化できる類似のものはありますか? ある画面専用にインラインで作られた単発の実装がよくあります。少し変更すれば汎用化できるなら、それを共有ライブラリに移し、ハードコードされた部分(ラベル、色、サイズ)をプロパティやトークンでパラメーター化し、画面固有の前提を取り除いてください。ほぼ同じものをもう一つ作るのではなく、そうしてください。
- 既存のものが要件に合わず、妥当な形で汎用化できない場合に限り、新しく作ってください。その場合は、ファミリーに自然に加わるよう、以下の共通DNAに基づいて作ります。
似たようなボタンが3つ、角丸の異なるカードが2つある、といった単発の実装が並行して存在すると、デザインシステムは一貫性を失っていきます。コンポーネントを追加する前に、これはすでに存在するか、既存のものをリファクタリングすれば作れるか? と考えてください。
逆の場合も同じです。2〜3か所で個別に使われているビジュアル表現は、共通コンポーネントやトークンとして名前を付けて昇格させる価値があります。4つ目の複製ができる前に対応してください。また、異なるデザイン時代のページ同士でスタイルが異なる場合は、古いものを新しいものに合わせて移行します。最新のコンポーネントは現在の意図を示す最良の証拠ですが、スタイルを廃止する前に確認してください。統合の手順については、generate-ui-from-brand を参照してください。
不整合を自動的に見つける(dembrandt エンジン、任意)。 稼働中の製品で、5つのボタンの角丸がほぼ同じなのに微妙に異なる、1つにまとめるべきグレーが3種類ある、といった差異を目視で見つけるのは面倒です。
get_findingsは実際の抽出結果に対してデザインシステムのリントを実行し、統合すべき一貫性や重複の問題を報告します。(また、compute_driftは2つの抽出結果の乖離度をスコア化します。たとえば、この製品と参照元、または整理の前後を比較できます。)既存の製品で何を再利用するか決める前に、これらを使って監査してください。詳細はextract-designを参照してください。
共通DNA
これらのトークンを一度だけ定義します。すべてのコンポーネントはこれを継承します。
角丸
すべてのインタラクティブコンポーネントで、同じ基本の角丸トークンを使います。バリエーションは導き出すものであり、新たに作るものではありません。
--radius-base: 8px; /* buttons, inputs, selects */
--radius-sm: 4px; /* checkboxes, small badges */
--radius-lg: 12px; /* cards, modals, large panels */
--radius-full: 9999px; /* pills, tags, avatar chips */
同じフォーム内のボタンと入力欄は、同じ角丸にする必要があります。ピルには常に --radius-full を使います。バッジにはブランドのトーンに応じて --radius-sm または --radius-full を使いますが、すべてのバッジで一貫させてください。
入れ子になった角は同心円状にします。 角丸のボックスが別のボックスの内側にある場合、外側の角丸は内側の角丸に両者の間隔を足した値になります。8px のボタンの周囲に 12px のパディングがあるカードでは、12px ではなく 20px が必要です。これを誤ると、数ピクセル離れた角の曲率が異なります。誰もそれを言葉にしませんが、誰もが気づきます。
.card {
padding: var(--space-3); /* 12px */
border-radius: calc(var(--radius-base) + var(--space-3)); /* 8 + 12 = 20px */
}
合計値をハードコードするのではなく、calc() で導き出してください。そうすれば、どちらかのトークンを変更しても角が正しく保たれます。
ボーダーのスタイル
すべてのフォームコンポーネントとコンテナのボーダーには、厳選したトークンのみを使用します。
2段階ルール: ボーダー幅は、最大でも2種類(例:1pxと4px、または1pxと8px)に制限します。1px、2px、3px、4px...のような段階的なスケールは使用しないでください。選択肢を絞ることで、階層が明確になり、製品に意図が感じられます。
--border-width-thin: 1px; /* Default for inputs, cards, dividers */
--border-width-thick: 4px; /* Featured items, bold accents, active indicators */
--border-color: var(--color-border);
--border-color-focus: var(--color-primary);
--border-color-error: var(--color-error);
入力フィールドとセレクトのボーダーは、通常時は同一です。フォーカス状態では、どこでも--border-color-focusを使用します。エラー状態では、どこでも--border-color-errorを使用します。
スペーシングと高さ
同じ視覚的スケールのコンポーネントは、高さと内側のパディングを共有します。
/* Default (md) size */
--component-height-md: 2.5rem; /* 40px */
--component-padding-x-md: 0.75rem; /* 12px */
--component-padding-y-md: 0.5rem; /* 8px */
/* Small */
--component-height-sm: 2rem; /* 32px */
--component-padding-x-sm: 0.5rem; /* 8px */
--component-padding-y-sm: 0.375rem; /* 6px */
/* Large */
--component-height-lg: 3rem; /* 48px */
--component-padding-x-lg: 1rem; /* 16px */
--component-padding-y-lg: 0.625rem; /* 10px */
ユーザーがテキストを拡大してもコントロール内にラベルが収まるよう、高さとテキストのパディングにはremを使用します。ボーダーとシャドウには引き続きpxを使用します。[[sizing-units]]を参照してください。
隣り合うボタンと入力フィールドは、同じ高さでなければなりません。これは見た目だけの問題ではありません。高さがそろっていないとフォームのレイアウトが崩れ、雑然とした印象になります。
高さを指定し、導き出さないでください。 パディングだけでサイズを決めたコントロールの高さは、line-height + パディング + ボーダーになります。この3つのいずれかが異なると、同じ行にある2つのコントロールの高さがずれてしまいます。
- アウトライン付きのバリエーションは、ボーダーなしのものより2px高くなります。
- 可変またはクランプされたフォントサイズでは、ビューポートによって行ボックスの高さが変わります。
- テキストではなくアバターやアイコンをコンテンツに持つアイテムは、固有の高さが異なります。
同じ行にあるすべてのコントロールに同じ明示的な高さを設定し、コンテンツを中央に配置してください。min-heightとして設定すれば、通常時は形状を保ちつつ、想定よりコンテンツが大きい場合には切り取らずに対応できます。
ピクセルではなく、ボックスモデルを基準に議論してください。 ボーダーが高さに含まれるかどうかはbox-sizingで決まります。これを明確にすれば議論に決着がつきます。微調整をめぐる議論はいつまでも終わりません。参加者それぞれが、同じ指定によって生じる異なる症状を説明しているためです。content-boxではボーダーが高さに加算されるため、アウトライン付きコントロールはゴーストの兄弟要素より2px高くなり、中央揃えにすると1pxずれて配置されます。TailwindやほとんどのリセットCSSでデフォルトのbox-sizing: border-boxでは、ボーダーは高さに含まれるため、ボックスの高さは一致し、バリエーションでボーダーを追加または削除しても位置は変わりません。
border-boxは問題をなくすのではなく、内側に移します。ボーダーが上下の内側のスペースをそれぞれ1pxずつ使うため、コンテンツが中央揃えでないコントロールや、残りの内側のスペースが奇数ピクセルのコントロールでは、片側の丸めがずれます。これはgrepでは見つけられない1pxのずれです。解決策は、ボーダーの有無にかかわらず、常にボックス内にボーダーを確保することです。ボーダーなしのバリエーションにもborder: 1px solid transparentを指定すれば、ボーダーを表示するかどうかにかかわらず内側のスペースが同じになります。
高さを導き出す方法にも、検索で見つけられない欠陥があります。 明示的な高さは、grepやdiffで確認できる1つのトークンです。導き出した高さは、3つの異なる宣言から生じる性質です。そのため、8つのコントロールのうち1つだけ行の高さが違っていても、検索では見つけられません。ユーティリティクラスを使うコードベースでは、同じパディングが異なる順序で記述されています(rounded-md px-3 py-2とrounded-md border transition-colors px-3 py-2など)。検索と置換を行っても、一部は修正され、残りは気づかれないまま見落とされます。見落としはそれぞれ2pxで、単独では目立ちません。そして、それが「修正」した後も行の見た目が崩れたままの理由です。
唯一の方法:1つのクラスが行を所有する
兄弟要素間で同じ値を繰り返すと、行の一貫性が崩れます。後から編集するたびに、すべての複製を探さなければならないためです。代わりに、一度だけ宣言してください。行内のすべての項目で1つの共有クラスを使い、そのクラスがボックス、コンテンツスロット、すべての状態を所有します。各インスタンスで設定できるのは色だけです。
.control {
box-sizing: border-box;
height: var(--control-h); /* set, never derived */
display: inline-flex;
align-items: center;
gap: var(--control-gap);
padding-inline: var(--control-px); /* including its responsive steps */
border: 1px solid transparent; /* borderless variants keep the border, transparent */
border-radius: var(--radius-base);
font-size: var(--control-font); /* declared here, not on the label inside */
cursor: pointer; /* browsers give `button` cursor: default */
}
/* Opt-in, not `.control > *`: a descendant selector reaches icons that set
their own dimensions and stretches them. One content slot for every child
that carries content: avatar, label, icon, count. Equal boxes do
not make an equal row, and a control holding an avatar next to one holding a
text line looks uneven precisely when both measure identical. Standardising
the slot is not drawing everything at one size: inside a 20px slot an icon
can be 16px and a chevron 12px and the row still reads level. */
.control-slot { height: var(--control-slot); display: inline-flex; align-items: center; }
.control:hover { /* one definition for the whole family */ }
次の3つのルールを守れば、一貫性を保てます。
- 行内のどの要素も、自身の高さ、パディング、フォントサイズ、角丸、状態を設定してはいけません。 あるコントロールにクラスにない設定が必要なら、そのクラスにバリアントを追加してください。
- バリアントで変更できるのは色だけです。 バリアントがボックスに手を加えた時点で、それは1つのクラスを装った2つ目のクラスです。
- 状態はクラスで編集し、個々のインスタンスでは編集しません。 これは実際に起こるリグレッションです。ボックスを正しく作っても、数か月後に兄弟要素の1つだけに新しいホバー状態、新しいフォーカスリング、新しいトランジションが追加され、行の一貫性が崩れます。8つのコントロールが並ぶ行では、ライフサイクル全体でボックスの編集は1回なのに対し、状態の編集は何十回にもなります。そのため、最も効果があるのは状態のルールです。
アイコンの重心はボックスの中央にはありません
align-items: centerで中央に配置されるのは、アイコンのボックスです。通常、ボックス内に描かれた形状はボックスの中央にありません。そのため、水平に見えるアイコンでも、ラベルの横では低く、あるいは高く見えます。星は頭部に、ダウンロード矢印は底部に重心が偏っています。アイコンごとにオフセットを設定し、セット全体で同じ補正値を共有しないでください。形状ごとに補正量が異なるため、共有値を使うと必ず、あるアイコンでは補正しすぎ、別のアイコンでは補正が足りなくなります。同じ行に対して2つのコミットが逆方向に調整しているなら、どちらかが間違っているのではなく、共有定数を使っている証拠です。
見た目が合うまで調整するのではなく、測定してください。グリフをラスタライズし、アルファチャンネルの重心を求め、中央配置するボックスの中心と比較します。
from PIL import Image
a = Image.open("icon.png").convert("RGBA").split()[3] # alpha = ink coverage
px, w, h = a.load(), *a.size
tot = sum(px[x, y] for y in range(h) for x in range(w))
cy = sum(px[x, y] * y for y in range(h) for x in range(w)) / tot
offset_px = (h / 2 - cy) / (h / RENDER_PX) # negative: lift the icon
結果はアイコンごとのオフセットとしてマークアップに反映し、コメントに測定値を記載します。アイコンを差し替えたら、再測定してください。もっともらしく聞こえても、実際にはそうではない点が2つあります。
- 先にぼかしても、答えが変わることはほとんどありません。 目が重心を捉える方法を近似するのは理にかなっていますが、小さくて塗りつぶされたグリフでは、ガウスぼかしによる重心の移動はピクセルの百分の一程度です。ぼかしが有効なのは、境界ボックスの重みには影響する一方で、目にはほとんど影響しない細い突起を持つ形状に限られます。
- 色で重心は変わりません。 単色のグリフでは、輝度によってすべてのピクセルが同じ定数倍でスケールされるため、重心は変わりません。色によってテキストの横にあるアイコンの見た目の重さは変わりますが、これはサイズとウェイトの判断であり、配置の判断ではありません。
同じ考え方は、マークとして使う単独の文字にも当てはまります。オフセットは文字形状の重心に応じて決まり、2つの文字で符号が異なることも当然あります。
カーソルもクラスで管理します。 どのブラウザーでも<button>はcursor: defaultで描画され、フレームワークのリセットでも必ずしも修正されるとは限りません。Tailwind v4のpreflightでは修正されません。カーソルはポインター操作のユーザーが得る最も手軽な操作の手がかりであり、ホバー時の色が変わる前に伝わる唯一の手がかりです。そのため、クリックできそうに見えるのに矢印のままのコントロールは、操作できないように見えます。ホバー状態はたいてい実装されていて、カーソルだけが間違っているため、レビューでも見逃されがちです。preflightの内容はメジャーバージョン間で変わるため、思い込みではなく確認してください。grep -n "cursor" node_modules/tailwindcss/preflight.css。ネイティブタグを使わずにインタラクティブ要素にした場合(<div role="button">)、カーソル、フォーカススタイル、キー操作の処理が必要です。カーソルだけの対応は、その中でも最も表面的なものです。
これらのコントロールを端から端までつなぐ場合、ファミリーの角丸を適用するのはグループであり、子要素ごとではありません。外側の角には--radius-baseを保ち、内側の継ぎ目は角丸をなくすことで、バー全体が1つのコントロールに見えます。共有する境界線も1本にまとめてください。隣接する1pxの境界線が2本あると、2pxの継ぎ目が描画され、ファミリーのほかの要素にはない太さになります。
コントロールが開くときも、同じ考え方が縦方向に当てはまります。セレクト、複数選択、日付ピッカー、コンボボックスと、その下に開くパネルはひと続きの境目です。トリガーは上の角にファミリーの角丸を保ち、下の角を直角にします。パネルは上の角を直角にし、下の角にファミリーの角丸を適用します。これにより、開いたコントロールは、上下に積み重なった二つではなく、縦に伸びた一つの形として見えます。境目に何を置いても、細い区切り線、小さなキャレットの切り欠き、何も置かない場合でも、角の形は変わりません。パネルのボーダーは、同じ幅と色で続くトリガーのボーダーです。また、開いている間もトリガーの高さは --control-h のままです。
残高とアバター、セグメントコントロール、ボタンを取り付けた入力欄のように、複数のコントロールを一つの共有サーフェスにまとめるグループでは、ラッパーに高さを固定し、子要素にも設定してください。ストレッチだけに頼ると、後から align-items を変更したり、子要素を絶対配置にしたりしたときに、レイアウトの副作用がひそかに取り除かれます。
クラスを導入することが危険なステップであり、具体的に二つの失敗が起こりえます。 どちらもレビューでは見過ごされやすく、画面ではすぐに分かります。
隣接するユーティリティに負けないようにする必要があります。 ユーティリティファーストのコードベースでは、すべてのインスタンスに引き続き色のクラスが適用されています。また、フレームワークの後に読み込まれる通常のスタイルシートは、それらの優先度を上回ります。クラスはフレームワークのコンポーネントレイヤー(@layer components、または同等の @layer 順序)に配置し、シャorthandではなくロングハンドプロパティを記述してください。border: 1px solid transparent とすると、ユーティリティで設定された border-color がリセットされるため、すべてのボタンのボーダー色が透明に塗り替えられます。同じ落とし穴は background、padding、font、transition にも当てはまります。
既存のトークンを使用し、値をそのまま書き直さないようにする必要があります。 古いマークアップから 8px をコピーしてリテラル値にすると、トークンが分岐します。今日の行は正しくても、次にトークンが変更されたときに追従しなくなります。var(--radius-md)、var(--control-h) などを参照し、必要な値にトークンがない場合は追加してください。
導入後は、もう一度測定してください。クラスが正しくても結果がずれていることがあり、測定には数秒しかかかりません。
推測せず、測定してください。 ボックスのサイズが違う場合、ボックスは正しくてもコンテンツの量が違う場合、サーフェスの装飾によって正しい行が台無しになっている場合は、どれも「高さがずれている」ように見えます。そのため、どれかを推測して直すと、間違ったものを修正してしまいます。アプリの実際のコンパイル済みスタイルシートを使い、ヘッドレスブラウザーで2種類のビューポート幅にて行を描画し、各コントロールを確認してください。
[...document.querySelectorAll('[data-control]')].map(el => {
const r = el.getBoundingClientRect(), c = getComputedStyle(el);
return { h: r.height, top: r.top, fontSize: c.fontSize, padLeft: c.paddingLeft };
});
height が同じならボックスの大きさが一致していることが分かります。top が同じなら、同じベースラインを共有していることが分かります。fontSize や padLeft が異なる場合は、パラダイムが一致していないことを数値で示せます。単なる議論ではありません。
行を構築するすべてのシェルを確認してください。 同じツールバーが複数の場所で組み立てられていることがよくあり、画面に表示されているものが、開いたファイルのものとは限りません。修正しても画面に変化がない場合は、修正が間違っていたのではなく、編集したファイルが違っています。
ボタンの横にボタン以外の要素をまとめる
ツールバーには、残高、ステータス、アバター、件数など、ボタンではない要素を載せることがよくあります。ボタンの列に何のまとまりもなく配置すると、壊れたボタンのように見えてしまいます。解決策は共通領域です([[gestalt-ui-organisation]]を参照)。「これらはひとまとまりで、あちらとは異なるものです」と示す一つの面を設け、要素ではなく面によってグループ化します。
領域の表現方法を、控えめなものから順に示します。
| 表現 | 見え方 | 使用する場面 |
|---|---|---|
| 背景の色合い(中立色を2~8%) | 静止した面 | デフォルト。アウトライン付きボタンの横に置いても競合しないほど控えめです |
| 枠線 | コンテナー | すでに枠線のないボタンが並んでいるため、枠線で区別できる場合 |
| 内側の影 | くぼんだ井戸 | グループがアクションの集合ではなく、入力や表示のための領域である場合 |
| グラデーション | 浮き上がった立体的な面 | まれなケース。取り除こうとしていたボタンのアフォーダンスを再び加えてしまいます |
どの方法を選ぶ場合も、次の点に注意してください。
- ボタンと
border-radiusを完全に一致させます。 横に並ぶ要素の角丸が異なると、仲間ではなく異質な要素に見えます。 - 高さを合わせます。 上記と同じルールです。グループは一つのコントロール分の垂直方向のスペースを占めます。
- 表現方法は一つにします。 色合い、枠線、内側の影をすべて加えるのは、同じことを三通りで表現するようなものです。ツールバーほど小さいスケールでは、要素がその重みを受け止められません(「小さなコンポーネントの抑制」を参照)。
- 子要素はシンプルに保ちます。 塗りつぶしや枠線はそれぞれに付けません。領域ですでにグループ化されているためです。状態はホバーのみとし、静止状態の面より少し強調します。
- 子要素のホバーでグループの見た目を変えないようにします。 ラッパーには枠線と区切り線の色を独自に設定し、固定します。各要素が単独ボタンのスタイルを再利用していると、一つの区画にホバーしただけでユニット全体の輪郭が変わり、グループがちらついて見えます。
- グループごとにアイコンを一つ使い、区画ごとには使いません。 三つの区画それぞれに同じダウンロードアイコンを付けるのは、ラベル付きの選択肢が三つあるのではなく、アイコンを三回繰り返しているだけです。狭い幅でラベルが収まらない場合などに限り、アイコンを表示します。
方向が意味を伝えます。 浮き上がりとくぼみは、同じ二つの効果を逆向きにしたものですが、押せることと読み取り用であることという、正反対の役割を示します。くぼんだ面には三つの要素すべてが必要です。暗から明へ下向きに変化するグラデーション、上端の内側の影、そして井戸の奥行きを閉じる下端の細い明色の線です。最後の線を省くと、上端の影が奥行きではなく汚れのように見えます。最初の二つを逆にすると、避けようとしていた上から光が当たるボタンを作り直すことになります。
/* Recessed: a readout. */
background-image: linear-gradient(180deg, rgba(0,0,0,0.06), rgba(0,0,0,0.02));
box-shadow: inset 0 1px 2px rgba(0,0,0,0.07), inset 0 -1px 0 rgba(255,255,255,0.6);
両方のテーマを個別に調整してください。ライトテーマでは控えめなくぼみに見える同じアルファ値も、ダークテーマでは平坦に見えたり、にじみのように見えたりします。
影
インタラクティブなコンポーネントでは、一貫した影のルールを使います。
- 静止時:影なし。フローティングコンポーネント(セレクトのドロップダウンを開くトリガーなど)では
--shadow-xs - フォーカス時:
box-shadowをフォーカスリングとして一貫して使う場合を除き、outlineでフォーカスリングを表示します - 浮き上がった状態(コンポーネントから開くドロップダウンやポップオーバー):
--shadow-md
色のルール
同じ色の役割を、すべてのコンポーネントで一様に適用します。
| 状態 | 色トークン |
|---|---|
| 静止時の枠線 | --color-border |
| フォーカス時の枠線/リング | --color-primary |
| エラー時の枠線 | --color-error |
| 無効状態 | 不透明度を下げた--color-text-secondary |
| 選択時/アクティブ時の塗りつぶし | --color-primary |
| ホバー時の背景 | 不透明度8~12%の--color-primary |
1種類のインタラクション言語
上記のカラーテーブルでは各状態の「見た目」を定義しています。このルールでは、プロダクトで許容するインタラクションパターンの「数」を定めます。1つ選んで再利用してください。同じプロダクト内で、ホバー/アクティブ/インタラクションのパターンを3~4種類も使わないでください。
ユーザーは、今どのサイトにいるかをすでに把握しています。プロダクト間での多様性は当然ですが、1つのプロダクト内での多様性は負担になります。新しいパターンが増えるたびに、タスクの途中で覚えることが増えるからです。そこで、パターンを統一します。
- ホバー時の反応を1つにする。 インタラクティブ要素がホバー時に浮き上がるなら、すべて同じように浮き上がらせます。背景色が変化するなら、すべて同じように変化させます。関連するコンポーネント間で、浮き上がり、下線、色の切り替え、拡大縮小を混在させないでください。
- アクティブ/押下時の反応を1つ、フォーカスリングを1つ、選択状態の表現を1つにし、どこでも同じように適用する。 これが、上記のカラーの役割がコンポーネントごとの設定ではなく、共有トークンになっている理由です。
- モーションの特徴を1つにする。 同じ「種類」の遷移には、同じイージングと継続時間を使い、ホバー、表示、切り替えが同じ人の手によるもののように感じられるようにします(
micro-interactionsを参照)。
一貫性のあるインタラクションの語彙を繰り返し使うことで、プロダクトは学びやすくなります。ユーザーは一度パターンを覚えれば、どこでもそのパターンを信頼できます。
コンポーネントファミリーの構成要素
| コンポーネント | 角丸を共有 | 高さを共有 | ボーダーを共有 | 色のロジックを共有 |
|---|---|---|---|---|
| ボタン | ✓ | ✓ | —(塗りつぶし) | ✓ |
| 入力欄/テキストエリア | ✓ | ✓ | ✓ | ✓ |
| セレクト | ✓ | ✓ | ✓ | ✓ |
| チェックボックス | --radius-sm | — | ✓ | ✓ |
| ラジオボタン | --radius-full | — | ✓ | ✓ |
| トグル/スイッチ | --radius-full | ✓ | — | ✓ |
| ピル/タグ | --radius-full | ✓ | ✓(任意) | ✓ |
| バッジ | --radius-sm または --radius-full | — | — | ✓ |
| 日付選択/カレンダー | --radius-base | ✓ | ✓ | ✓ |
| スライダー | --radius-full(トラック+つまみ) | — | — | ✓ |
| セグメントコントロール/ボタンバー | ✓(外側の角のみ) | ✓ | ✓(共有) | ✓ |
| 検索入力欄 | ✓ | ✓ | ✓ | ✓ |
| コンボボックス | ✓ | ✓ | ✓ | ✓ |
共通の特徴と、明確な役割
共通のデザイン要素によって、コンポーネント同士に「関連性」を持たせます。ただし、「見分けがつかない」状態にしてはいけません。特にリスクが高いのは、共通点が最も多い組み合わせです。たとえば、ピル型のボタンとピル型のバッジ、ボーダー付きのボタンとボーダー付きの入力欄です。両者の区別が曖昧になると、ユーザーは何も起こらないバッジをクリックしたり、ラベルに見えるボタンを見逃したりします。
ルールは、操作する前に役割が読み取れることです。見た目だけで、クリック可能なもの、編集可能なもの、読み取り専用のものをユーザーが判別できるようにします。
- ボタン — クリック可能:塗りつぶし、またははっきりしたボーダーを使い、
cursor: pointerを指定して、ホバー時の反応を加えます。 - バッジ/タグ — 読み取り専用:控えめな塗りつぶし、小さめの文字を使い、ホバー時の反応もポインターカーソルも絶対に付けません。この2つのサインはインタラクティブ要素のためのものであり、形が同じボタンとバッジを明確に区別します。
- 入力欄 — 編集可能:ボーダーで囲み、内部は空にして、プレースホルダーとテキストカーソルを表示します。
入力欄とボタンを横に並べた組み合わせは、最も頻繁に見分けがつかなくなります。 どちらも同じ高さの角丸ボックスで、1px のボーダーを持ちます。ボタンがゴーストまたはアウトラインのバリエーションになると、両者は同じ見た目になります。高さは同じに保ちます。これがファミリーとしての共通性です。違いは内側に持たせます。入力欄は空のままプレースホルダーとテキストカーソルを表示し、ボタンには塗りつぶしまたははっきりしたボーダー、ボタン用のウェイトで設定したラベル、ポインターを持たせます。違いが入力欄にテキストが入っているかどうかだけなら、フォームはプレースホルダーが1つ変わるだけで曖昧になります。
塗りつぶし+サイズ、ボーダー+カーソルなど、少なくとも2つの視覚的な手がかりで区別します。色だけ、また高さだけで区別してはいけません。目を細めて確認してみてください。ラベルが読めなくても、ボタン、バッジ、入力欄を見分けられますか? できない場合、ファミリーは1つのコンポーネントに崩れてしまっています。
セマンティックチップコンポーネント
汎用の Badge コンポーネントは誤用されがちです。同じコンポーネントがステータス、コードトークン、キーボードショートカット、カテゴリラベルに使われ、そのスタイル上書きがコードベース全体に散らばってしまいます。
このパターンでは、多くのバリアントを持つコンポーネントを1つ作るのではなく、意味ごとにコンポーネントを1つ作ります。
製品のインラインラベルは通常、少数の明確に異なる意味に分類できます。それぞれの意味に対してコンポーネントを定義してください。よくある例を以下に示します。
| コンポーネント | 意味 | 形状 |
|---|---|---|
Tag | カテゴリラベル、ステータス、フィルター | ピル(rounded-full) |
Code | インラインリテラル、パス、キー | <code>、等幅フォント、控えめな角丸 |
Kbd | キーボードショートカット | <kbd>、等幅フォント、控えめな角丸 |
Metric | 測定値(1.2s、42px) | 等幅フォント、控えめな角丸 |
必要に応じて、製品固有のタイプを追加してください(例:CLI 製品の Flag、API 製品の Token)。新しいタイプを追加するたびに、既存コンポーネントの新しい variant プロパティではなく、専用のコンポーネントを作成します。
各コンポーネントが表す意味は1つだけです。見た目はその意味に基づいて決まり、呼び出し側から色や形状のプロパティを渡すことはありません。
サイズ: チップが置かれるテキストのコンテキスト(見出し、本文、キャプション)にかかわらず適切なサイズで表示されるよう、パディングには em 相対値を使用し、コンテキストごとの上書きは不要にします。
const BASE = "inline-flex items-center align-middle whitespace-nowrap border leading-none";
const PILL = "text-[0.85em] px-[0.6em] py-[0.25em] rounded-full font-medium";
const CHIP = "text-[0.85em] px-[0.5em] py-[0.2em] rounded-[0.4em] font-mono";
export function Tag({ children }: { children: ReactNode }) {
return <span className={`chip-tag ${BASE} ${PILL}`}>{children}</span>;
}
export function Code({ children }: { children: ReactNode }) {
return <code className={`chip-code ${BASE} ${CHIP}`}>{children}</code>;
}
export function Kbd({ children }: { children: ReactNode }) {
return <kbd className={`chip-kbd ${BASE} ${CHIP}`}>{children}</kbd>;
}
色: セマンティックごとの色は、1つのファイル内の CSS クラス(chip-tag、chip-code など)で管理してください。色のプロパティをインラインで指定しないでください。こうすることで、ライト/ダークモードの設定を1か所にまとめ、チップのカラーパレット全体を一目で確認できます。
.chip-tag, .chip-code, .chip-kbd, .chip-metric {
background-color: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.09);
color: var(--text-secondary);
}
.chip-tag { color: var(--text-primary); }
後方互換性: 既存の呼び出し箇所で汎用の Badge が使われている場合は、最も一般的なセマンティックバリアントをデフォルトとして再エクスポートしてください。これにより、移行せずに古いインポートを引き続き使用できます。
1行内の配置
ラベル、バッジ、ステータスドット、値、アイコンなど、異なる種類の要素が1行に混在する場合は、サイズがばらばらな要素の寄せ集めではなく、配置のそろった1行として見える必要があります。
- 行内の文字サイズを統一します。 バッジやチップの隣にあるテキストは、周囲の書体サイズに合わせます。バッジによって行のサイズが気づかないうちに縮小したり拡大したりしてはいけません。チップの
BASEと同じようにleading-noneとalign-middleを使い、すべての要素を共通の中心線上に配置します。 - ステータス表示を中央に配置します。 信号機のようなドット(赤/アンバー/緑)は、注釈対象のテキストに対して垂直方向の中央に配置します。ベースラインではなく、ラベルのキャップハイトの中心に合わせます。
- テキストの隣のバッジは、テキストに合わせます。 ベースラインに合わせるか、xハイトを基準に視覚的に中央配置します。隣の要素に対して中央に見えるよう、バッジのパディングを左右対称に広げます。バッジの内部だけで中央に配置してはいけません。
- 視覚的な中心線を1本にします。 バッジ、テキスト、アイコンが上下に揺れていると、それぞれの要素単体に問題がなくても、行全体が崩れて見えます。
数量バッジは円形を保ちます
数量バッジのサイズには border-box を使い、高さと min-width を同じ値に設定し、水平方向のパディングだけを指定します。こうすれば、2桁になったときに横幅が広がります。すでに左右のパディングと境界線がある汎用のピル型バッジに固定幅を設定しないでください。content-box では両方が加算され、20px の円はラベルのように見える42pxのピルになります。
小さなコンポーネントでは控えめに
コンポーネントは小さくなるほど、表現できることも限られます。大きなサイズでは無難に見える控えめなデザインが、小さなコンポーネントの読みやすさを保ちます。
- 複数の枠線や、枠で囲みすぎたコンテナーは避けてください。 枠線のあるレイヤーを重ねないでください(枠線付きのチップを枠線付きのセルに入れ、さらに枠線付きのカードに入れるなど)。小さな要素では重さを受け止めきれません。枠線は1つにするか、使わずに、代わりに塗りや余白を活用してください。2ステップの枠線ルールを小さなサイズに適用したものです。
- アイコンは最大1つにしてください。 ピル、バッジ、行にアイコンを2つか3つ置くと、雑然として、どれが操作可能なのか分かりにくくなります。さらにアイコンが必要なら、そのコンポーネントはサイズに見合わなくなっています。より大きなパターンに切り替えてください。
- カードを入れ子にしたコンテナのデザインは見直してください。 カードの中にカードを重ねるデザインは、余白や階層ではなく、ボックスでグループ化しようとするものです。[[gestalt-ui-organisation]]を参照し、フラットにして、空白と見出しでグループ化し、カードの表現は最も外側にある意味のあるコンテナーだけに使ってください。
ブランドでグラデーションを使う場合は、一貫性を保ってください。
- プライマリボタンにグラデーションを使う場合、製品内のほかの箇所で使われているグラデーションと、角度や色の段階を同じにしてください。
- ホバー状態では、色相ではなく、グラデーションの明るさを少し変えてください。
- ボタンの種類によってグラデーションと単色を使い分けないでください。種類ごとにどちらか一方を選び、すべてに統一して適用してください。
製品で使われているマークを再利用する
状態(同じ、変更、追加、削除)を示すマークが必要な新しいビューでは、製品内ですでに使われているマークを探し、そのまま再利用してください。同じ概念に別の表現を使うと、読者に2つの表現を覚えさせることになり、ほかの場所ですでに定着している意味とも矛盾します。
レビューチェックリスト
- 同じフォーム内のボタンと入力欄の高さは統一されていますか?
- その高さは、パディング、行の高さ、ボーダーの合計に任せず、明示的に設定されていますか?
- 行内のすべてのコントロールは、同じ値を繰り返し指定するのではなく、共通のクラスを使用していますか?
- そのクラスはコンポーネントレイヤーに配置され、詳細度の低いプロパティを使用しているため、インスタンスのユーティリティが優先されますか?
- 値をコピーせず、既存の角丸、高さ、スペーシングのトークンを参照していますか?
- コントロール内のすべての子要素(アバター、ラベル、アイコン)は、同じコンテンツスロットの高さに収まっていますか?
- すべての状態がそのクラスで定義されているため、新しいホバーやフォーカスリングが一部の兄弟要素だけに適用されることはありませんか?
- 高さは推測ではなく、実際のスタイルシートを使い、複数のビューポートで測定しましたか?
- ボーダーのあるすべてのコンポーネントで、ボーダー幅の選択肢は最大2種類(例:1pxと4px)に収まっていますか?
- フォーカス状態は、フォーカス可能なすべてのコンポーネントで同じ見た目ですか?
- エラー状態は、エラーが発生しうるすべてのコンポーネントで同じ見た目ですか?
- 製品全体で再利用する単一のインタラクション言語(ホバー時の反応、アクティブ/押下時の反応、フォーカスリング、モーションの特徴がそれぞれ1種類)がありますか?それとも、3〜4種類のパターンが競合していますか?
- すべての角丸の値は、コンポーネントごとに個別に設定せず、同じベーストークンから導出されていますか?
- ピルとタグでは、一貫して
--radius-fullを使用していますか? - ボタン、バッジ、入力欄は、見た目だけで区別できますか(目を細めて見るテスト)?また、インタラクティブでない要素にはホバー時の反応やポインターカーソルがありませんか?
- グラデーションを使用している場合、すべてのボタンバリエーションで一貫していますか?
- 新しいコンポーネントを作る前に、既存のライブラリ(およびコードベース)を確認し、そのまま使えるものや、小さな変更で汎用化できる単発のものがないか調べましたか?複製するのではなく、確認しましたか?
- 新しいコンポーネントは、既存のトークンだけを使ってライブラリに追加できますか?
- インラインラベル、ステータス、コードトークン、キーボードヒント、メトリクスは、汎用バッジのバリエーションではなく、それぞれ独立したコンポーネントですか?
- チップ/バッジコンポーネントは、テキストのコンテキストに合わせて拡大縮小するよう、
em相対のサイズ指定を使用していますか? - チップの色は、インラインプロパティではなく CSS クラスで定義されているため、ライト/ダークの設定を一か所にまとめられていますか?
- テキスト、バッジ、アイコンが混在する行では、すべて同じ文字サイズとセンターラインを共有していますか?
- ステータス/信号機インジケーターは、ラベルに対して垂直方向に中央揃えされていますか?
- 小さなコンポーネントでは、ボーダーの重なりや入れ子(囲まれたような見た目)を避けていますか?
- 小さなコンポーネントに含まれるアイコンは最大1つですか?
- カードの入れ子を避け、スペーシングと階層構造を活用して、カード内カードの構造をフラット化していますか?
- 新しいビューでは、製品ですでに使われている状態マークとアイコンを再利用し、独自のものを追加していませんか?