ドキュメント
デザインガイド
Native Shell でアプリを作る際、コンポーネントを選んだりレイアウトのコードを書いたりする前に、このガイドを読んでください。ここには、GPUI Kit が長年のデスクトップ開発で培ったプロダクト上の判断を、NSH 向けに引き継いでまとめています。インターフェースは、ネイティブに感じられ、控えめで、正確で、推測なしに理解できるものであるべきです。
これは規範的なガイドです。Must(必須)は正しさやエコシステム上の制約を示し、should(推奨)は覆すのに具体的な理由が必要な既定の方針、may(任意)は任意の手法です。個々のプロパティやメソッドについては、引き続き Elements と API のページが正式な情報源です。
これらのルールは、nsh/base(Rust では nsh_base)の挙動、テーマとそのトークン、そして各プラットフォームのユーザーがすでに知っているインタラクションを土台にしています。NSH は各プラットフォーム自身の UI でレンダリングし、見た目の決定はアプリに委ねます。nsh/base のコンポーネントは挙動、状態、フォーカス、アクセシビリティを提供し、色、サイズ、角丸、余白はすべてアプリ自身のコンポーネントから決まります。したがって、これらのルールはアプリのデザインシステムを導くものです。shadcn/ui は有用な手法(オープンなコード、コンポジション、信頼できる既定値)をもたらしますが、NSH アプリの見た目を決めるものではありません。複数の影響が衝突した場合は、プラットフォームの挙動と、人々がすでに理解しているインタラクションを優先してください。
#デザインの基本方針
ネイティブで、静かで、正確に感じられるインターフェースを作ります。体験はコンテンツ、階層、インタラクションに担わせ、装飾はそれらを支えるにとどめ、競合させないようにします。
- 個性より明快さ。 ブランド表現を加える前に、主要なタスクと次のアクションを明確にします。
- 発明よりコンポジション。 まず確立されたコンポーネントから始め、それらを組み合わせてプロダクト固有のワークフローを作ります。新しいプリミティブを作るのは、その挙動が本当に新しい場合だけです。
- 値よりトークン。 色、角丸、タイポグラフィ、余白は一つのシステムを成すべきです。テーマに追従できない孤立したリテラルは避けます。Styling、Tailwind、the theme を参照してください。
- Web の慣習よりプラットフォーム。 タスクの助けになる場合は、デスクトップではキーボード操作、ウィンドウクローム、メニュー、高密度のデータビュー、サイズ変更可能な領域、常設のナビゲーションを維持し、スマートフォンやタブレットではシステムのヘッダー、戻るジェスチャー、アラート、タッチターゲットを維持します。
- 状態は見えなければならない。 ホバー、押下、フォーカス、選択、無効、読み込み中、バリデーション、破壊的操作の各状態には、区別でき、一貫した表現が必要です。
- ジオメトリは包含関係を説明する。 角丸、インセット、境界は、サーフェスが重なる場合でも、一貫した一つの階層を表すべきです。
- フィードバックは現実を反映する。 読み込み表示、ツールチップ、モーションは、実際の状態や遷移を明確にするために使い、即時のアクションに儀式めいた演出を加えてはいけません。
#Shadcn から学ぶ
Shadcn の最も有用な貢献は、特定の枠線の色ではありません。システムの作り方そのものです。
- 閉ざされた抽象化と戦うのではなく、インターフェースの最上位レイヤーを自分たちで所有する。
- 小さく予測可能な部品を組み合わせて、プロダクト固有のコンポーネントを作る。
- 最初から一つのビジュアル言語を形成している既定値を提供する。
- コードとコンポジションを、人にも AI にも読みやすく保つ。
- 挙動のプリミティブとスタイルを持つレイヤーを分離する。
NSH はこれらの考え方を、nsh のプリミティブ、nsh/base のスタイルなしの基底コンポーネント、そしてアプリが所有するスタイルレイヤーという分割によって適用しています(Three layers)。アプリケーションは、TSX またはアプリの Rust クレートで、nsh/base の上に自分のコンポーネントを構築します。再利用可能な挙動は nsh/base に置き、見た目の方針はその上位に置きます。
次のような Web の前提を、無批判にコピーしないでください。
| Web の習慣 | NSH の既定 |
|---|---|
| すべてのボタンでポインティングハンドのカーソル | 既定のカーソル。ポインティングハンド(cursor-pointer)はリンクにのみ使用 |
| ページ遷移を主な構造にする | デスクトップでは常設のウィンドウ、ペイン、サイドバー、タブ、メニュー。スマートフォンではシステムの戻る操作を備えた NavStack |
| フォールバックとしてのブラウザのフォーカスとスクロール | 明示的なフォーカスの所有と、領域ごとのスクロール管理(Scroll、List) |
| モバイルファーストの単一カラム | スマートフォンでは単一カラム。デスクトップでは、最小ウィンドウサイズを定めたサイズ変更可能なシェル |
| ホバーで表示される重要なアクション | ホバーに依存せず、キーボード、タッチ、ポインターのいずれからも到達できるアクション |
| ホバー時のみ表示されるアイコンボタンの列 | 常に見える主要アクションと、補助的なコマンド用の Menu または ContextMenu |
| アプリケーションのコマンドにリンク風のテキストを使う | アプリの default、outline、ghost スタイルの Button。リンクのスタイルは URL、Web リソース、メールアドレスにのみ使用 |
| あらゆる場所で大きなタッチ密度 | タッチスクリーンではタッチ向けのサイズのターゲット。ポインターでは中程度の密度。コンパクトにするのは情報作業で効果がある場合のみ |
| すべてのプラットフォームで独自に描画するヘッダーとアラートカード | iOS と Android ではシステムのヘッダーと AlertDialog。デスクトップと Web ではページの header スロット |
| 子孫要素にまたがる CSS の上書き | アプリのコンポーネント、その部品、そしてテーマのトークン |
#タスクから始める
画面を描く前に、次の事項を書き出してください。
- ユーザーの主要なタスク
- 閲覧または変更の対象となるオブジェクト
- 常にすぐ使える状態でなければならないアクション
- 判断を下すために必要な情報
- 空、読み込み中、エラー、オフライン、読み取り専用、権限拒否の各状態
- ワークフローにおけるキーボード、タッチ、スクリーンリーダーの経路
- アプリがどこに表示されるか。単独か、ホストアプリの内部か
画面はこれらの答えを中心に構成します。ダッシュボードのグリッドやコンポーネントのカタログから始めてはいけません。優れたインターフェースは、ユーザーのメンタルモデル、つまりドキュメント、アカウント、プロジェクト、メッセージ、設定など、安定したオブジェクトを表に出します。内部のサービスアーキテクチャを見せるものではありません。
コントロールを配置する前に、主要なタスクを設計します。その視覚的な重み、位置、情報の深さは、プロダクトにおけるそのタスクの重要性に見合うものにします。中核となる結果を、小さな件数、隅のアイコン、弱いフッターのアクションに押し込めたまま、補助的なコンテンツがページを占めてはいけません。結果セットがプロダクトの主な価値である場合は、件数、代表的な結果、意味のある状態、明確な次のアクションを示すサマリー領域やカードを検討してください。
提案するすべてのアクションについて、見えている対象、現在の状態、スコープ、結果を明示します。インターフェースがそれらを示していない、または明確に示唆していないなら、そのアクションは時期尚早です。バックエンドにあるからという理由だけで機能を公開してはいけません。まず、そのオブジェクトがユーザーのメンタルモデルにどう入るかを設計します。
#ビジュアル言語
#階層
少数の明確なレベルを使うことを推奨します。
- ウィンドウまたはページのタイトルは、現在のオブジェクトやワークスペースを示す
- セクションタイトルは、意味のある領域を区切る(
role="header"。スクリーンリーダーがジャンプできるようにするため) - 本文テキストは、作業の内容を担う
- ミュートされたテキストは、補足的なメタデータやヘルプを提供する
- ラベルは、コントロールや値を識別する
色やコンテナを加える前に、サイズ、ウェイト、余白、区切り線を使います。カードの中にカードを入れ子にするのは避けてください。ほとんどの領域に必要なのは、背景、細い境界線、意図的な余白だけです。
階層は、コンポーネント単位ではなく、機能全体で評価します。レビューではアクセントカラーと装飾を隠してみてください。それでも、主要なタスク、現在の選択、結果のサマリー、次のアクションが構造から明らかであるべきです。個々には妥当なコントロールを含む画面でも、それらが一つの読み順と一つの判断経路を形成していなければ、失敗しえます。
強調は限られた予算として扱います。一つのローカルなサーフェスに必要なのは、競合するハイライトの群れではなく、明確な焦点一つです。すべてに色が付き、バッジが付き、太字になり、囲まれ、アラートに格上げされていたら、何も重要には見えません。優先度はまず構造と近接性で示し、強い色やコンポーネントは、その区別がユーザーの注目や行動を変える場面にだけ使います。
#色とテーマ
色はテーマから読み取り(Rust では theme()、TSX ではトークンクラス)、意味上の役割に従って使います。
- 主要なサーフェスとテキストには
backgroundとforeground - 名前の付いたサーフェスには
card、popover、sidebarと、それぞれの foreground トークン - 補助的な情報には
mutedとmuted-foreground - 主要なアクションや選択の強調には
primary、より控えめな強調と、ホバーまたはフォーカスされた行にはsecondaryとaccent destructiveは破壊的なアクションとエラーにのみ使い、アプリがテーマに追加するステータスカラー(success、warning)は、それぞれの意味にのみ使う- データ系列には
chart-1からchart-5 - 構造とインタラクションには
border、input、ring
use nsh_base::theme;
view()
.rounded(move || theme().radius)
.border_1()
.border_color(move || theme().border)
.bg(move || theme().background)
.p_4()
.gap_1()
.child(text("Balance").text_color(move || theme().foreground))
.child(text("Updated just now").text_sm().text_color(move || theme().muted_foreground))<View class="gap-1 rounded-(--radius) border border-border bg-background p-4">
<Text class="text-foreground">Balance</Text>
<Text class="text-sm text-muted-foreground">Updated just now</Text>
</View>意味を持つステータスカラーを装飾として使ってはいけません。色だけで意味を伝えてもいけません。すべてのカスタムサーフェスは、ライトテーマとダークテーマ、およびカスタムのテーマ値(自分のアプリを埋め込むホストアプリのテーマを含む)で確認してください。前景が黒、背景が白だと決めつけてはいけません。
バッジは、短い状態、件数、分類のうち、素早く見分けられることが有益なものに使います。すべてのラベル、メタデータの値、フィルター、セクションタイトルに使うものではありません。ほとんどのバッジはニュートラルな色に保ち、意味のある色は、成功、警告、破壊的、情報といった意味を本当に担う状態のためにとっておきます。多色のバッジが並んでいるなら、たいていは階層やグルーピングの判断が欠けています。
アプリケーションの UI に、生の 16 進数、rgb()/rgba()、hsl() の色、あるいは bg-zinc-100 のようなパレットクラスを含めてはいけません。色は意味上の役割に従ってテーマから解決します。必要な役割が存在しない場合は、呼び出し側にパレットの値を埋め込むのではなく、アプリのテーマファイルで定義してください(Theme tokens:light と dark の中の "up": "#16a34a" により、TSX では text-up、bg-up/30、token("up")、Rust では theme().color("up") が使えるようになります)。生の色を置いてよいのは、テーマ定義の内部か、色そのものがデータである、監査済みのデータやラスターコンテンツに限られます。
#角丸、余白、密度
タイプやコントロールのサイズと同様に、角丸も有限のスケールを使います。テーマの radius(コントロールにもサーフェスにも共通、rounded-(--radius))、アプリがテーマに追加した角丸、Tailwind の rounded-sm … rounded-2xl の間の値)から選び、要素ごとに角丸を発明しないでください。大きなコンテナは、中に含まれるコントロールよりも柔らかい段階を使ってもかまいません。コンパクトなコントロールやオーバーレイは、より小さい段階を使います。通常の長方形では、角丸は短辺の半分より目に見えて小さく保ちます。rounded-full(.rounded_full())は、意図した円やピル形にのみ使い、ツールチップが意図せずカプセル形になってはいけません。
入れ子のサーフェスは、同心の形として読めるようにします。inner radius ≈ max(0, outer radius − gap) を使って最も適した角丸を選び、角を曲がるところでも見える帯の幅が均一に保たれるようにします。繰り返し使うコンポジションに中間の値が必要な場合は、名前付きのトークン("radius-inset": "0.375rem"、rounded-(--radius-inset))をテーマに追加してください。セグメントコントロールは一つのシルエットです。両端のセグメントはコンテナの内側の角に従い、中間のセグメントは角を四角いままにし、各境界には一貫した太さの区切り線を一本だけ置きます。
角丸は、境界線だけでなく、見えているサーフェス全体を支配しなければなりません。露出した角は背後のサーフェスに対して透明のままにし、子要素の背景、選択の塗り、スクロールするコンテンツも境界に従う必要があります。内側のサーフェスに独自の角丸を与えるか、overflow-hidden(.overflow_hidden())でコンテナをクリップし、その後、両方のテーマでコントラストのある背景に対してコンポジションを確認します。クリップの方法を選ぶ際は、外側に出るフォーカスリングが損なわれないようにしてください。
コンパクトな余白のスケールを使い、それを繰り返します。関連するラベルとコントロールのペアは、別々のグループよりも近づけ、別々のグループは別々のセクションよりも近づけます。独自の高さを使うより、アプリのコンポーネントのサイズ(small、既定の medium、large)を優先します。コンパクトなバリアントは、ツールバーやデータ密度の高い画面に使い、不明瞭なレイアウトを狭い領域に押し込むために使ってはいけません。
共有される意味上のスケールは意図的に小さく、余白はおおよそ 2、4、8、12、16、24、32 ポイントで進み、タイポグラフィは 12、14、16、18、20 ポイント付近にとどまります。これらは関係性として捉えるべきもので、現在の値を機能のコード全体にばらまいてよいという許可ではありません。Tailwind の余白スケールがこれらを与えます。1 ステップは 4 ポイントで(p-4 は 16pt)、クラスでも型付けされた Rust のメソッドでも同じです。nsh.json の "tailwind" にある "spacing" はクラスのステップを変更し、型付けされたメソッドは Tailwind の既定のスケールを保ちます。別のスケールが必要なアプリケーションは、それを自身のコンポーネントまたはテーマトークン(p-(--gap-card))で所有し、一貫して使用します。
#空間の文法
余白は関係を表します。2つの要素が互いにどのような意味を持つかを考えて、セマンティックなスケールから間隔を選びます。
| 関係 | 代表的なクラス | 現在のスケール | 例 |
|---|---|---|---|
| 光学補正 | gap-0.5 | 2 pt | アイコンのベースライン、コンパクトな区切り |
| 1つのコントロールの部品 | gap-1 | 4 pt | メニューのアイコンとラベル、タイトルと説明 |
| 密接に関連するコントロール | gap-2 | 8 pt | ボタンのアイコンとラベル、ダイアログのアクション |
| 1つのコンテンツグループ | gap-3 | 12 pt | 通知の列、コンパクトなフォーム行 |
| 1つのセクション内の別グループ | gap-4 | 16 pt | パネルのパディング、フォームのグループ |
| 別のセクション | gap-6 | 24 pt | ページやインスペクターの主要なブロック |
| 主要な領域の境界 | gap-8 | 32 pt | 空状態のゆとり、ページの帯 |
これらの値はデフォルトのスケールを示すものであり、そのまま繰り返し書くリテラルではありません。スペーシングクラスと型付きメソッド(gap-2、.gap_2()、p-4、.p_4())、またはテーマのトークンを使用してください。プロダクト独自のスケールは、順序と関係性を保ったうえで、各呼び出し箇所に直接書かず、アプリケーション自身のコンポーネントを通して渡す必要があります。
水平方向と垂直方向の余白を決めるときは、次のルールに従います。
- 外側より内側が先。 コンポーネントのパディングはそのコンポーネントに属し、コンポーネント間の間隔は親に属します。
- 垂直方向のリズムでグルーピングを示す。 タイトルと説明の間隔は、その説明から次のセクションまでの間隔より小さくします。間隔が等しければ、関係も等しいと受け取られます。
- 水平方向の余白はスキャンを助ける。 繰り返される行では、アイコン、ラベル、値、バッジ、末尾のアクションを安定した列に保ちます。
- leading と trailing はセマンティック。 読み順の端で考え、start と end(
ps-*、me-*、.ps_4())として記述することで、レイアウトが右から左の言語でも反転します。 - パディングを二重にしない。 すでにパディングのあるパネルに置いたカードが、自動的にパネル全体分のインセットをさらに加えてはいけません。
- 光学的な整列は控えめに使う。 アイコンやグリフの形状に対する1〜2ポイントの補正は有効ですが、スケールと異なる理由を文書化してください。
一般的な構成例は、これらの関係を示しています。
- ボタンの内容は、小さいサイズでは 4 pt、通常サイズでは 8 pt を使用する
- ダイアログのヘッダーとフッターは、内部の間隔に 8 pt を使用する
- コンパクトなリストとメニューの行は、垂直方向に 4 pt、水平方向に 8〜12 pt のパディングを使用する
- シートのヘッダーは、閉じる操作のための余地を残すため、leading に約 16 pt、trailing に 12 pt を使用し、フッターは水平方向に 16 pt、垂直方向に 12 pt を使用する
- 通知は、アイコン、メッセージ、アクションがそれぞれ別のグループであるため、水平パディングに 16 pt、列の間隔に 12 pt を使用する
これらをあらゆる画面にそのままコピーするレシピとして扱わないでください。これらが示しているのはシステムの考え方です。コントロールは内部がより詰まり、行はスキャンしやすさを最適化し、コンテナは内容の間隔よりも境界により多くの余白を使います。
#比率とレイヤーの階層
まずコンテンツの要件から始め、そのうえで比率を決めます。1つのペインの役割が明確に異なる場合は、任意の半分ずつの分割を避けてください。
- ナビゲーションのサイドバーは、ラベルが安定して表示される幅を確保しつつ、作業領域に対して明確に従属して見える必要があります。割合だけに頼らず、最小幅、推奨幅、最大幅(
min-w-*、w-*、max-w-*)を指定してください。 - マスター・ディテールのレイアウトでは、コレクションをスキャンしやすく保ち、余剰はディテールのペインに与えます。おおよそ 1/3 と 2/3 の配分が出発点として有用なことが多いですが、コンテンツの制約が優先されます。電話では、この2つは
NavStackの2つのページになります。 - インスペクターや補助的なシートは、デフォルトで主要なオブジェクトを覆わないようにします。内容が増えたときにリサイズや非表示ができるようにしてください。
Sheetは、バックドロップ、Escape、戻る操作で閉じます。 - ダイアログの幅は判断の内容から決まります。短い確認には
AlertDialog、中程度のフォーム、複雑な作業には専用のウィンドウやページを使います。余白を作るためだけにダイアログを大きくしないでください。 - 最も強いエレベーションは、最上位の意思決定レイヤーのために取っておきます。同じレイヤー内では、段階的に大きくなる影ではなく、背景とヘアラインで階層を示します。
主要な領域ごとに、3つのサイズ制約を定義してください。タスクがまだ機能する最小サイズ、快適なデフォルトサイズ、そして余剰をどのように消費するかです。ユーザーが操作する分割がワークフローの好みを表す場合は永続化し、復元した値は現在のウィンドウに合わせてクランプしてください。
#整列の詳細
整列は最後の仕上げではなく、構造的なシステムです。各画面に、共有される leading と trailing の端、テキストのベースライン、中心線、固定の機能レーンといった、少数の整列スパインを確立してください。同じレベルの要素は、異なる種類のコンポーネントであっても、上から下、あるいは leading から trailing まで、同じスパインに沿うようにします。
垂直の赤い線は、コンテンツ、ステータス、時刻、末尾のアクションについて、共有される端やコントロールの中心の横に置かれています。水平線は、テキストのベースラインの下、または行の中心を通り、下端揃えと垂直中央揃えを示します。コンパクトな比較図では、レンダリング上1ピクセルのずれを取り出しており、これは構造上の所有者で修正する必要があります。
- 兄弟の領域には、共有のコンテンツインセットを与えてください。同じレベルを表す見出し、ツールバー、リスト行、空状態、フッターが、それぞれわずかに異なる leading の端を独自に作ってはいけません。
- 列のジオメトリは領域全体で繰り返します。ヘッダー、行、サマリー、ローディング状態、インラインエディターは、識別情報、メタデータ、ステータス、数値、アクションのために同じレーンを確保する必要があります。
- 行やセクションをまたいで、関連するコントロールを揃えます。フォームのラベル、フィールド、説明、バリデーションメッセージは、ページを上から下へ走査したときに、安定した垂直方向のグリッドが見えるようにします。
- 水平方向の帯の一貫性を保ちます。ツールバー、ヘッダー、ステータスバー、行を共有する項目は、個別に調整したオフセットではなく、1つのベースラインまたは中心線を使います。
- インデントは、実際の階層、包含、開閉表示がある場合にのみ導入します。装飾的なインデントは兄弟要素を従属的に見せ、画面の読みの線を壊します。
- ネストされたレベルが終わるときは、親のスパインにぴったり戻ります。入れ子のコンテナ間でパディングが蓄積してずれないようにしてください。
- オプションのコンテンツがあってもスパインを保ちます。アイコン、バッジ、説明、末尾のアクションが欠けても、残りのラベルが動いてはいけません。行をまたいだ比較が重要な場合は、意図的なスロットやレーンを使ってください。
- 階層が一致する主要な領域同士を揃えます。サイドバーのヘッダー、コンテンツのタイトル、分割ペイン、ツールバー、下部バーはすべての座標を共有する必要はありませんが、同じレベルのものは連続した線として見えるようにします。
すべての端を揃えるべきではありません。子要素はインデントでき、主要な値はそれを補足するメタデータより先頭に置け、破壊的な決定には間隔を加えられます。こうした例外は、階層や意味を伝えるものでなければならず、調整されていないコンポーネントのパディングの結果であってはいけません。まず共有のスパインから始め、そのうえで例外を明示的にしてください。
正確な整列と繰り返される間隔は、品質上の不変条件として扱います。2つの端や間隔が等しいことを意図している場合、レンダリング上1ピクセルの違いは、許容できる光学的な近似ではなく、欠陥です。代表的なウィンドウサイズ、テキストサイズ、ディスプレイのスケール係数で、解決済みの境界を計測ツールで検査してください。座標と距離を比較し、何気なくスクリーンショットを見ただけで整列を承認しないでください。
レンダリングピクセルの許容差は検証のルールであり、生のポイントオフセットでコードにパッチを当てる許可ではありません。等しい関係は、同じスペーシングクラスまたはメソッド、トークン、グリッド定義、共有コンポーネントのインセットから解決される必要があります。異なる場合は、共通の所有者を修正してください。フラクショナルなレイアウトとデバイスの丸め(2× や 3× の電話、フラクショナルなデスクトップのスケール)を考慮し、意図したスパインが特定のスケールでずれず、同じ物理ピクセルに収まるようにします。
- 異なるサイズが1つの行に混在する場合は、バウンディングボックスの中心ではなく、ベースラインでテキストを揃えます。
- アイコンは固定スロットの中央に配置し、アイコンの本来の幅が異なってもラベルが動かないようにします。
- 比較される数値は end に揃え、等幅数字(
tabular-nums)を使用します。散文や識別子は、ロケールが別の扱いを求めない限り、start に揃えます。 - 行の末尾のアクションと開閉インジケーターは、固定幅のレーンに置きます。
- フォームのコントロールは、下にあるヘルプテキストではなく、操作可能なフレームで揃えます。
justify-between(.justify_between())は、両側が本当に反対側の端を所有している場合にのみ使用してください。中間の構造の欠如を隠すために使ってはいけません。- ヘアラインは境界の所有者に属します。隣接する2つの領域が、それぞれ同じ区切り線を描いてはいけません。
- スクロールバーは、スクロールする領域に属し、そのパネル、エディター、ウィンドウの trailing の端に接します。コンテンツのパディングはテキストや行をインセットしてもかまいませんが、スクロールバーを画面の途中に引き込んではいけません。パディングはスクロールビューではなく、スクロールビューのコンテンツに付けてください。
#密度のティア
デフォルトは medium です。密度は、孤立した1つのコントロールではなく、ローカルなコンテキスト全体で変更します。
- comfortable / large: オンボーディング、疎なフォーム、目立たせたい決定
- standard / medium: アプリケーションの大部分のクロームとワークフロー、およびすべてのタッチスクリーン
- compact / small: デスクトップのツールバー、メニュー、テーブル、繰り返される業務用データ
- extra compact / xsmall: 例外的な高密度のユーティリティ。アプリケーション全体の自動的な選択にはしない
任意のサイズ指定ではなく、上限と下限のあるスケールを使用してください。タッチスクリーンでは、押下可能なターゲットはすべて少なくとも 44 × 44 ポイントにします(タッチターゲット)。ポインターを使う場合、ボタンは一般に約 24、28、32 pt のフレームで推移し、入力は large サイズで約 40 pt まで広がり、テーブルの行は約 28、32、40 pt を使います。アプリのコンポーネントには、タイポグラフィ、アイコン、パディング、ヒットターゲットを一緒に変えるサイズを与えてください。外側のボックスだけを変えるカスタムの高さは、通常は不完全です。
#テキストサイズ、基本フォント、ポイント
よく設計されたスケールは、テキストが大きくなっても階層を保ちます。大きなテキストが成功しているというのは、単にすべてのオブジェクトが大きくなったことではなく、タイトルと本文、コントロールとアイコン、内側と外側の間隔、主要な領域と副次的な領域の関係が、どのサイズでも同じように感じられることです。
NSH の長さの単位はポイントです。ユーザーのテキストサイズ(iOS の Dynamic Type、Android のフォントサイズ、デスクトップのテキストスケーリング、ホストアプリ独自の設定)はテキストをスケールします。フォントのスケーリングを許可するすべてのテキストと入力は大きくなりますが、スペーシング、角丸、ボーダー、画像のサイズは変わりません(テキストサイズ)。アプリの基本テキストサイズ(Tailwind の text-base、16 ポイント)は、タイプスケールが基準にする本文サイズです。
比率で設計してください。
- タイプのステップは、基本の本文サイズを中心に同じ階層を保つ
- スペーシングのステップは、タイプを中心に同じグルーピングの関係を保つ
- コントロールのフレームとヒットターゲットはラベルとともに大きくなる。固定の高さではなく、コンテンツと最小値(
min-h-11)で サイズを決める - ペインの最小幅と快適な幅は、大きなテキストを考慮する
- 角丸とフォーカスの扱いは、コントロールのフレームと光学的に一貫させる
テキストは独自に大きくなるため、その周りの固定フレームは破綻します。固定の高さのボタンの中で大きくなったラベル、固定のペイン最小幅の中で大きくなったドキュメント、固定の行の高さの中で大きくなった行は、元のリズムを壊し、コンテンツが切り取られる原因になります。テキストが大きくなったときは、行を折り返すか、列に切り替えてください(fontScale()、font_scale())。また、34pt のタイトルのようにすでに大きいテキストのスケールには上限を設けます(maxFontSizeMultiplier)。
原則として、アプリケーションのレイアウトにリテラルのポイント値(p-[13px]、px(13.0))を書いてはいけません。スペーシングのスケール(p-2、gap-3、w-64、text-sm とその型付きメソッド)、またはアプリのコンポーネントサイズを使用してください。リテラルのポイントは、値が物理的またはラスターの境界を表す場合にのみ使います。ヘアライン、プラットフォームのウィンドウインセット、ビットマップの寸法、最小のヒットテスト許容差、外部のサーフェスと一致させる必要があるジオメトリなどです。これらは監査され、文書化された例外です。プロダクトのスペーシング、タイポグラフィ、アイコンサイズ、通常のコントロールのジオメトリは、スケール上に留めてください。
デフォルトだけでなく、複数のテキストサイズでテストしてください。階層、折り返し、切り詰め、ウィンドウの最小サイズ、ペインのリサイズ、フォーカスリングのクリアランス、ポップアップの配置、リスト行の計測を検証します。
#サーフェスとエレベーション
エレベーションは重要度ではなく、重なり順を示すために使います。ベースとなるサーフェスはフラットにし、区切り線と背景のコントラストで領域を分けます。ポップオーバー、メニュー、シート、ダイアログ、通知は、他のコンテンツの上に載るため、段階的に強い影を使ってかまいません。すべてのカードに影を付けないでください。
同じ種類のサーフェスはすべて、1つの処理を共有させます。ポップアップ系には、popoverトークン、ボーダー、radius、1つの影からなるテーマ付きサーフェスを1つ用意し、Popover、Menu、ContextMenu、Tooltipがばらばらにならないようにします。アプリが別のアンカー付きサーフェスを独自に作る場合は、無関係なボーダーや影のリテラルで近似せず、この処理を再利用してください。
#タイポグラフィとアイコン
インターフェースのテキストにはプラットフォームの UI フォントを使い、等幅フォントはコード、識別子、ショートカット、桁を揃えたい数値データにだけ使います。本文は読みやすく保ち、特に CJK のテキストでは、過度な大文字化や字間の調整を避けてください。
1つのプロダクトでは1種類のアイコンファミリーを使います。アイコンはラベルを補うものであり、なじみのない操作を推測に頼らせるために置き換えてはいけません。アイコンだけのボタンには必ずアクセシブルな名前(accessibilityLabel)が必要です。視覚的な説明は、後述のツールチップのポリシーに従います。塗りつぶしや色付きのアイコンは状態を伝えるために使い、ツールバーを賑やかにするためだけに使わないでください。
#レイアウトパターン
#安定したシェルを選ぶ
ほとんどのアプリケーションは、次のいずれかのシェルを使います。
- シングルワークスペース: 1つの主要なビューの上にヘッダーまたはツールバーを置く
- ナビゲーションスタック: ページをプッシュ/ポップする(
NavStack)。スマートフォンではシステムの戻るジェスチャーを使う - サイドバーワークスペース: 常時表示のナビゲーションの横に、切り替わる詳細ビューを置く
- マスター・詳細: サイズ変更可能なコレクションペインと詳細ペイン。スマートフォンでは2ページにする
- ドキュメントワークスペース: 長く扱う複数のオブジェクトをタブで切り替える
- ユーティリティウィンドウ: 短く固定された操作の流れで、1つのタスクに集中する
コンテンツが変わっても、グローバルナビゲーションは安定させます。主要な作業領域には、flex-1(.flex_1())で残りのスペースを与え、はみ出す子要素を縮める必要がある場所では min-w-0 / min-h-0 を使います。スクロールや仮想化を入れ子のビューで作り直さず、Scroll と List を本来の用途どおりに使ってください。
デスクトップでは、アプリ独自のタイトルバー(nsh/desktop)は、まずウィンドウクロームです。ドラッグ領域(.window_drag(true))を維持し、通常のタイトルクリックを、頻度の低い編集コマンドに割り当てないでください。名前の変更は、明示的なオブジェクトコマンドの背後に置き、必要に応じてキーボードでの手段も用意します。
#レスポンシブなウィンドウ
ウィンドウのサイズは自由です。ユーザー、分割ビュー、ホストアプリのいずれかが決めます。ウィンドウが狭くなったときの動作は、次の順で決めます。
- 主要なタスクを維持する
- サイズ変更可能な領域が、文書化された最小値に達するまで縮められるようにする
- 補助的なラベルやインスペクターを折りたたむ
- 頻度の低い操作をメニューに移す
- 実際にコンテンツがあふれている領域だけをスクロールさせる
別の手段を用意せずに操作を隠してはいけません。リストやドキュメント本文だけをスクロールさせればよい場合に、ウィンドウ全体をスクロールさせないでください。ブレークポイント(md:、lg:)と windowSize()(Rust では window())は、NSH が描画する先のサーフェスに従います。
NSH のフレックスレイアウトには、他のレイアウトシステムと同じ固有サイズの圧力があります。flex-1 の子要素でも、長いコンテンツがあると縮むのを拒む場合があります。どのペインを縮小、省略、折り返し、スクロールさせてよいかについて、デザインと実装で合意してください。クリップされた領域は、外側に張り出すフォーカスリングもクリップします。オーバーフローの処理を単純にするためだけに、キーボードでの視認性を犠牲にしてはいけません。
#フォームと設定
各フィールドには見えるラベルを付け、ヘルプや検証メッセージは、説明対象のフィールドの隣に置きます。関連するフィールドは揃えますが、長いラベルを狭い固定幅の列に押し込まないでください。コントロールは用途に合ったものを選びます。独立した選択には Checkbox、見えている少数の選択肢には Radio を並べた RadioGroup、より多い選択肢には選択肢の Menu、すぐに反映される設定には Switch を使います。これらのパーツ(スイッチのトラックとつまみ、チェックボックスのインジケーター)はアプリ側でスタイルを指定します。コントロールに追従する checked:、indeterminate:、disabled: クラスを使ってください。
use nsh_base::{switch, theme};
fn setting_switch(label: &'static str, on: Signal<bool>) -> impl IntoElement {
switch()
.checked(on)
.on_change(move |next: &bool| on.set(*next))
.flex_row()
.items_center()
.gap_3()
.track_style(|s| {
s.w(px(44.0))
.p(px(2.0))
.rounded_full()
.bg(move || theme().muted)
.transition_colors()
.checked(|s| s.bg(move || theme().primary))
})
.thumb_style(|s| {
s.size(px(20.0))
.rounded_full()
.bg(move || theme().background)
.shadow_sm()
.transition_transform()
.checked(|s| s.translate_x(px(20.0)))
})
.child(text(label).text_color(move || theme().foreground))
}import { Text } from "nsh";
import { Switch } from "nsh/base";
function SettingSwitch(props: { label: string; on: boolean; onChange: (e: { checked: boolean }) => void }) {
return (
<Switch
checked={props.on}
onChange={props.onChange}
class="flex-row items-center gap-3"
trackClass="w-11 rounded-full bg-muted p-0.5 transition-colors checked:bg-primary"
thumbClass="size-5 rounded-full bg-background shadow-sm transition-transform checked:translate-x-5"
>
<Text class="text-foreground">{props.label}</Text>
</Switch>
);
}「すべて選択」チェックボックスは、不確定状態で部分的な選択を示します。
use nsh_base::{checkbox, theme};
fn select_all(selected: Signal<usize>, total: usize) -> impl IntoElement {
checkbox()
.checked(move || selected.get() == total)
.indeterminate(move || selected.get() > 0 && selected.get() < total)
.on_change(move |all: &bool| selected.set(if *all { total } else { 0 }))
.flex_row()
.items_center()
.gap_2()
.indicator_style(|s| {
s.size(px(20.0))
.items_center()
.justify_center()
.rounded(move || theme().radius)
.border_2()
.border_color(move || theme().border)
.checked(|s| s.bg(move || theme().primary).border_color(move || theme().primary))
.indeterminate(|s| s.bg(move || theme().primary).border_color(move || theme().primary))
})
.indicator(text(move || mark(selected.get(), total)).text_color(move || theme().primary_foreground))
.child(text("Select all").text_color(move || theme().foreground))
}
fn mark(selected: usize, total: usize) -> &'static str {
if selected == total { "✓" } else if selected > 0 { "–" } else { "" }
}import { Text } from "nsh";
import { Checkbox } from "nsh/base";
function SelectAll(props: { selected: number; total: number; onChange: (e: { checked: boolean }) => void }) {
return (
<Checkbox
checked={props.selected === props.total}
indeterminate={props.selected > 0 && props.selected < props.total}
onChange={props.onChange}
class="flex-row items-center gap-2"
indicatorClass="size-5 items-center justify-center rounded-(--radius) border-2 border-border checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary"
indicator={
<Text class="text-primary-foreground">
{props.selected === props.total ? "✓" : props.selected > 0 ? "–" : ""}
</Text>
}
>
<Text class="text-foreground">Select all</Text>
</Checkbox>
);
}処理の実行中は送信を無効にし、ユーザーの入力を保持して、結果は操作の近くに表示します。入力欄はソフトウェアキーボードの上に保ちます(env(keyboard-inset-height))。ダイアログは、短く絞られた判断にだけ使います。探索が必要なワークフローや多くのフィールドがあるワークフローには、全画面のページやシートを使ってください。
ローディングの表現は、実際の待ち時間を表すものでなければなりません。すでにコンテンツが利用可能な設定パネル間の切り替えは、即座に行います。スケルトンは、コンテンツが本当に読み込み中で、その形が分かっている場合にだけ適切です。
#コンポーネントとコンポジション
コンポーネントは閉じたデザインシステムではなく、構築のための素材であるという Shadcn の原則に従います。nsh/base は振る舞いを提供し、アプリは自身のコンポーネントで一貫したデフォルトを提供し、コンポジションとプロダクトのセマンティクスを所有します。
- コンポーネントのバリアントは、意味に従って使います。プライマリは、判断の場面で明示的なデフォルトの確定操作にだけ使います。通常は、デスクトップで Return によって実行される操作です。単独の操作、頻繁な操作、望ましい操作が、自動的にプライマリになるわけではありません。管理ツールバーの
Addコマンドは通常、デフォルトのボタンを使い、フォームのデフォルトのCreateによる確定にはプライマリを使ってかまいません。破壊的な確定にはデストラクティブを、控えめなツールバー操作にはゴーストを使います。 - 任意の子孫要素にスタイルを当てるより、明示的なパーツとスロットを優先します。
- 繰り返し現れるパターンは、プロダクト全体で一貫させます。ドメイン固有の言語やポリシーを含む場合は、アプリケーションのコンポーネントとしてラップします。
- 意味上の役割には、標準のコンポーネントを使います。
Menu、ContextMenu、Popover、Sheet、Dialog、AlertDialogは互いに置き換えられる単なる箱ではありません。それぞれが、選択、フォーカス、キーボード、閉じ方、レイアウトについて異なる契約を持ち、デバイスごとに固有の形を取ります。 - コンポーネントファミリーのジオメトリを維持します。メニューの行は、上下左右のパディング、高さ、アイコンとチェックマークのスロット、区切り線、角丸、状態の表現を共有します。間隔がシステムに近似しているだけの独自ポップアップで、メニューを模倣しないでください。
- すべてのプロパティの名前を変えるためだけに、あるいはその機能をすべて固定するためだけに、コンポーネントをラップしないでください。
- プロダクト固有のスタイルを持たない再利用可能な振る舞いは、
nsh/baseか、アプリのプロダクト間で共有するクレートに移します。テーマ付きで主張のある表現は、アプリケーションのコンポーネントに置きます。
use nsh_base::{button, theme};
/// The footer of a form: a default Cancel and the primary commit.
fn form_actions(cancel: impl Fn() + 'static, create: impl Fn() + 'static) -> impl IntoElement {
view()
.flex_row()
.justify_end()
.gap_2()
.child(
button()
.on_press(cancel)
.rounded(move || theme().radius)
.border_1()
.border_color(move || theme().border)
.px_4()
.py_2()
.active(|s| s.bg(move || theme().muted))
.child(text("Cancel").text_color(move || theme().foreground)),
)
.child(
button()
.on_press(create)
.rounded(move || theme().radius)
.bg(move || theme().primary)
.px_4()
.py_2()
.active(|s| s.bg(move || theme().primary.opacity(0.9)))
.child(text("Create").text_color(move || theme().primary_foreground)),
)
}import { Text, View } from "nsh";
import { Button } from "nsh/base";
// The footer of a form: a default Cancel and the primary commit.
function FormActions(props: { onCancel: () => void; onCreate: () => void }) {
return (
<View class="flex-row justify-end gap-2">
<Button onPress={props.onCancel} class="rounded-(--radius) border border-border px-4 py-2 active:bg-muted">
<Text class="text-foreground">Cancel</Text>
</Button>
<Button onPress={props.onCreate} class="rounded-(--radius) bg-primary px-4 py-2 active:bg-primary/90">
<Text class="text-primary-foreground">Create</Text>
</Button>
</View>
);
}#インタラクションの状態
#クリックする前に結果が分かるようにする
コントロールは、その結果を予測できるものであるべきです。なじみのあるプラットフォームのコントロールと配置を使い、インターフェースを学ばなくても操作できるようにします。ラベルは操作と対象を示し、状態は利用可否を示し、フィードバックは同じ結果を確認させるものにします。
設定フローを開くボタンに Save というラベルを付けたり、グループから項目を取り除くだけのボタンに Delete というラベルを付けたりしないでください。文脈から範囲が明確でない場合は、範囲を明記します。操作には即座に反応し、時間のかかる処理中は二重送信を防ぎ、結果は変更されたオブジェクトの近くに表示します。成功メッセージは、結果そのものが見えない場合にだけ追加します。
すべてのインタラクティブなコントロールは、次の状態を想定して設計します。
| 状態 | NSH | デザイン要件 |
|---|---|---|
| Rest | 視覚的なノイズのない明確なアフォーダンス | |
| Hover | hover: | 控えめなポインターのフィードバック。唯一の手がかりにはしない |
| Pressed | active: | 即座に返る押下のフィードバック。スタイルはアプリが指定する |
| Open / pressed | ポップアップの open | アタッチされたポップアップが開いている間、持続するフィードバック |
| Focus visible | focus-visible: | コントラストの高いキーボードフォーカスリング |
| Selected / checked | selected:、checked: | ホバーとは区別できる持続的な状態 |
| Disabled | disabled: | 強調を下げ、誤解を招くホバーや押下の反応を返さない |
| Loading | コンテキストを維持し、二重操作を防ぎ、長い待ち時間を説明する | |
| Error | 何が起きたか、どう回復できるかを示す |
キーボードから使えるべきコマンドは、デスクトップとウェブで keymap にバインドします。なじみのあるプラットフォームのショートカットに合わせ("mod+k" は macOS では Command、それ以外では Control です)、ショートカットをメニューやツールチップに表示し、オーバーレイを開いた後や閉じた後は、フォーカスを論理的な位置に保ってください。
選択は、見た目を整えるための任意要素ではなく、情報モデルの一部です。タブ、セグメント化された選択肢、選択可能な行、フィルター、ナビゲーションの移動先には、持続的な選択状態を表示する必要があります(selected。スクリーンリーダーが読み上げます)。メニューを持つボタンは、ポップアップが閉じるまで、押された状態または開いた状態が見えるままにします。ホバーだけでは、トリガーとサーフェスの関係を説明できません。
use nsh_base::{tab, theme};
fn section_tab(label: &'static str, id: Section, current: Signal<Section>) -> impl IntoElement {
let selected = move || current.get() == id;
tab()
.selected(selected)
.on_press(move || current.set(id))
.rounded(move || theme().radius)
.px_3()
.py_2()
.selected_style(|s| s.bg(move || theme().muted))
.child(text(label).text_color(move || {
if selected() { theme().foreground } else { theme().muted_foreground }
}))
}import { Text } from "nsh";
import { Tab } from "nsh/base";
function SectionTab(props: { label: string; selected: boolean; onPress: () => void }) {
return (
<Tab
selected={props.selected}
onPress={props.onPress}
class="group rounded-(--radius) px-3 py-2 selected:bg-muted"
>
<Text class="text-muted-foreground group-selected:text-foreground">{props.label}</Text>
</Tab>
);
}セグメント化されたコントロールでは、選択された塗りと選択されていないサーフェスが、コンテナーのシルエットを共有する必要があります。どちらも、端の角を四角くしたり、境界を他より重く見せたりしてはいけません。
選択されたナビゲーション項目、リストの行、タブは、項目自身のサーフェスで示します。選択時の塗り、強い前景色、太いウェイトなどです。先頭側のバーや片側だけのボーダーを選択マーカーとして追加しないでください。これはウェブテンプレートの習慣であり、ネイティブの慣習ではありません。項目の角丸のシルエットを崩し、すでにテキストで揃っている列に、競合する2本目の縁を加えてしまいます。
破壊的な操作では、元に戻せる作業と元に戻せない作業を区別します。元に戻せる変更には、取り消しや一時的な通知を優先します。結果が深刻で取り消せない場合には、AlertDialog を使い、確認の文言で具体的な対象と結果を明示してください。
#ポインターの慣習
ボタン、チェックボックス、メニュー項目、タブ、その他のコントロールには、デフォルトのカーソルを使います。リンクや、リンクとして振る舞うコンテンツには、指差しの手(cursor-pointer)を使います。テキスト、リサイズ、グラブ、禁止のカーソルは、実際に行われている操作を表す場合にだけ使います。カーソルはアフォーダンスを補強するものであり、コントロールの視覚的な状態やアクセシブルなロールの代わりにはなりません。
タッチ、キーボード、アクセシビリティ関連の操作にはホバーがないため、ホバー効果は控えめにします。破壊的な操作や不可欠な操作の唯一の手段を、ホバー時にだけ表示しないでください。行ごとのコンテキスト操作は、同じコマンドが選択、キーボード、コンテキストメニューのいずれかで使えるなら、通常時は目立たなくしてかまいません。
#ホバーツールバーよりコマンドサーフェスを優先する
操作をどこに置くかは、コマンドの頻度と範囲で選びます。
- 主要な操作や頻繁な操作は、ラベル付きのボタンやなじみのあるツールバーのコントロールとして表示したままにする
- 現在の領域に対する補助的な操作は、見える
Menuのトリガーの背後に置く - ポインターや指の下にあるオブジェクトに作用するコマンドは、
ContextMenuに置く。デスクトップでは右クリック、タッチスクリーンでは長押しで開く - 自然なキーボード形式を持つ重要なコマンドは、キーボードショートカット(
keymap)でも使えるようにし、アプリケーションメニュー(nsh/desktopのMenu.setApplicationMenu)にも置く - ホバーで現れるアイコンは、ほかの場所からも到達できるコマンドへのショートカットとしてのみ使う
これは見た目の好みにとどまりません。nsh/base のメニューは、すでに次の機能を備えています。トリガーの位置への配置、デスクトップでの階層化されたサブメニュー、スマートフォンでのサブメニューをその場で開くシート、項目の選択・外側の押下・Escape・戻る操作による閉じ方、そしてスクリーンリーダー向けのメニューとメニュー項目のロールです。ホバーボタンを並べた独自のストリップは、これらの振る舞いを作り直す必要があり、タッチ、キーボードのみ、そして多くの支援技術を使うワークフローからは見えません。
use nsh_base::{menu, menu_item, menu_separator, theme};
use nsh_core::{color::black, element::StyleRefinement};
/// The app's popup surface, shared by its menus, popovers and tooltips.
fn popup(s: StyleRefinement) -> StyleRefinement {
s.min_w(px(192.0))
.p_1()
.rounded(move || theme().radius)
.border_1()
.border_color(move || theme().border)
.bg(move || theme().popover)
.shadow_lg()
}
fn item(label: &'static str, on_select: impl Fn() + 'static) -> impl IntoElement {
menu_item()
.on_select(on_select)
.px_3()
.py_2()
.rounded(move || theme().radius)
.hover(|s| s.bg(move || theme().muted))
.active(|s| s.bg(move || theme().muted))
.child(text(label).text_color(move || theme().foreground))
}
fn more_menu(rename: impl Fn() + 'static, duplicate: impl Fn() + 'static, delete: impl Fn() + 'static) -> impl IntoElement {
menu()
.offset(px(4.0))
.popup_style(popup)
.backdrop_style(|s| s.bg(black().opacity(0.5)))
.trigger(
view()
.label("More actions")
.size(px(44.0))
.items_center()
.justify_center()
.rounded_full()
.active(|s| s.bg(move || theme().muted))
.child(text("⋯").text_color(move || theme().foreground)),
)
.child(item("Rename…", rename))
.child(item("Duplicate", duplicate))
.child(menu_separator().h(px(1.0)).my_1().bg(move || theme().border))
.child(item("Delete…", delete))
}import { Text, View } from "nsh";
import { Menu, MenuItem, MenuSeparator } from "nsh/base";
/** The app's popup surface, shared by its menus, popovers and tooltips. */
export const popup = "min-w-48 rounded-(--radius) border border-border bg-popover p-1 shadow-lg";
const item = "rounded-(--radius) px-3 py-2 hover:bg-muted active:bg-muted";
function MoreMenu(props: { onRename: () => void; onDuplicate: () => void; onDelete: () => void }) {
return (
<Menu
offset={4}
popupClass={popup}
backdropClass="bg-black/50"
trigger={
<View accessibilityLabel="More actions" class="size-11 items-center justify-center rounded-full active:bg-muted">
<Text class="text-foreground">⋯</Text>
</View>
}
>
<MenuItem onSelect={props.onRename} class={item}><Text class="text-foreground">Rename…</Text></MenuItem>
<MenuItem onSelect={props.onDuplicate} class={item}><Text class="text-foreground">Duplicate</Text></MenuItem>
<MenuSeparator class="my-1 h-px bg-border" />
<MenuItem onSelect={props.onDelete} class={item}><Text class="text-foreground">Delete…</Text></MenuItem>
</Menu>
);
}ツールバーのオーバーフロー、ドキュメントの操作、アカウントメニューのように、さらにコマンドがあることをユーザーに視覚的に示す必要がある場合は、Menu を選びます。名前の変更、複製、表示、削除のように、選択やオブジェクトを対象とするコマンドには ContextMenu を選びます。コンテキストメニューを、不可欠なコマンドを実行する唯一の手段にしてはいけません。必要に応じて、メニューバー、ツールバー、キーボード、詳細ビューのいずれかの手段を用意してください。
画面をミニマルに見せるために、すべての操作をメニューに入れないでください。発見しやすさと速さが重要です。主要な操作は表示したままにし、危険な項目は明確にラベル付けして分離し、メニュー項目は、表示されるどの場所でも同じ動詞、アイコン、ショートカット、有効状態、結果を使います。
#Button はアプリケーションの操作、Link は外部リソース
操作によってアプリケーションの状態が変わる場合、判断を確定する場合、ツールを開く場合、データを送信する場合、コマンドを実行する場合は、Button を使います。見た目は、その場の階層に応じて選びます。
- primary button: 判断エリアで強調する唯一の確定操作に使います。
- default button: 通常の、常時表示される操作に使います。
- outline button: 強調は控えめにしつつ、操作の境界を明確にしたい場合に使います。
- ghost button: ツールバーやインラインでよく見かける、強調の低い操作に使います。
- icon button: 広く知られたシンボルの場合に限って使います。アクセシブルな名前を付け、意味や対象範囲が不明確な場合はツールチップも付けます。
画面上の操作がそれ 1 つだけだから、右上に配置されているから、あるいはチームがクリック数を増やしたいから、という理由で primary を割り当ててはいけません。primary は、既定の確定操作とキーボードでの動作を伝えるものです。項目の追加、ツールを開く、ビューの更新といった通常のコマンドであれば、その場の階層に応じて default、outline、ghost のいずれかの button を使います。
下線付きのリンク(role="link")は、外部リソースが対象の場合に限って使います。対象は URL、Web ページ、オンラインドキュメント、メールアドレスです。リンクは、現在のアプリケーションのコンテキストからそのリソースへ離れることを約束するものなので、ポインティングハンドのカーソルを使います。機能的なコマンドを控えめに見せるために、リンクのスタイルを使ってはいけません。Delete、Save、Refresh、Add、メニューを開く操作、アプリ内ナビゲーションをリンクの形にすると、コントロールのアフォーダンスが隠れ、誤ったアクセシビリティロールが公開されてしまいます。
「表示」という語があっても、アプリ内の遷移先がリンクになるわけではありません。詳細レポート、分析、詳細パネル、ローカルのレコードは、これまでどおり button、行、カード、タブ、ディスクロージャーコントロールから開きます。含まれているカードが何を開くかをすでに示している場合は、Full analysis のように、文脈に合った簡潔なラベルを使います。下線は、実際にブラウザーやメールクライアントで開くリソースのためにとっておきます。
サイドバーの行、タブ、パンくずリスト、リスト項目、ローカルビューを開く操作、ワークスペースの切り替えなど、すべての内部ナビゲーションには、ナビゲーションスタック、Button、または対応するコンポーネントを使います。視覚的な強調は、button の variant またはナビゲーションコンポーネントの選択状態で表現し、セマンティクスを偽って表現してはいけません。
#フィードバックとオーバーレイ
判断に見合う、最も小さなサーフェスを選びます。
- tooltip: 短い説明やショートカット(
Tooltip、または任意の要素のtooltip)。 - popover: 作業を中断しない、文脈に応じたコントロール(
Popover)。 - menu: 操作をまとめたコンパクトな一覧(
Menu)。 - notification: 判断を必要としない非同期のステータス。
- dialog: 集中して行う判断や短いフォーム(
Dialog)。 - alert dialog: 影響の大きい操作の明示的な確認(
AlertDialog)。 - sheet: より長く残るスペースが役立つ補助的な作業(
Sheet)。
それぞれ、アプリがスタイルを定める 1 つの構造から、デバイスに適した形で表示されます(popupClass、backdropClass)。
| デスクトップ | スマートフォンとタブレット | |
|---|---|---|
Tooltip | ポインターが要素の上にしばらく留まった後 | アプリが開いた場合のみ |
Popover | アンカーされ、外側を押すと閉じて、その操作もそのまま処理される | アンカーされ、外側を押すと閉じるだけ |
Menu | トリガーの位置に表示されるポップアップ。サブメニューはカスケード表示 | 下部に表示される sheet。サブメニューは項目の一覧と入れ替わる |
ContextMenu | セカンダリボタン。ポインターの位置に表示 | 指で長押しすると sheet が開く |
Sheet | 背景(バックドロップ)の上に表示されるエッジパネル | 同じ |
AlertDialog | プラットフォームの dialog スタイルのアラート | システムのアラートまたはアクションシート |
tooltip は不足している意味を補うものであり、自明な操作を繰り返し説明するものではありません。見慣れないシンボル、対象範囲が曖昧な場合、他では隠れてしまう便利なショートカットがある場合に限って使います。メッセージのフッターにある Copy や Download のように、繰り返し現れて見分けがつくコマンドには、アクセシブルな名前とキーボードアクセスが必要ですが、ホバー時のラベルは分かりやすさよりも煩わしさを増やします。スマートフォンでは、アプリが開いた場合にしか tooltip は表示されません。そのため、タッチ操作のユーザーが必要とする情報を tooltip に載せてはいけません。
アラートは、モーダルを開かない場合でも視覚的な階層に割り込みます。現在の作業で注意が必要な、重要かつ例外的な情報に使い、通常の説明、ヒント、空きスペースを飾るためのコンテナとして使ってはいけません。すぐに気づかせたり操作させたりする必要のない内容には、インラインヘルプ、控えめなテキスト、通常のセクションを使います。
オーバーレイを重ねるのは避けます。Escape キーと Android の戻るボタンは、閉じることのできる最前面のレイヤーを閉じます。アプリの状態は onOpenChange から同期し、フォーカスはトリガー、または次に適切な対象へ戻します。
オーバーレイ内の操作は、そのオーバーレイが実際に表示しているオブジェクトまたは状態を対象にします。たとえば Clear history は、独立した最近の履歴セクションが表示され、かつ項目がある場合にのみ公開します。検索結果、最近使った項目、お気に入りは別々のコレクションです。1 つの説明のないリストにまとめず、ラベルを付けて分けます。該当しない操作は非表示にするか、役立つ理由を添えて無効にします。曖昧なゴミ箱アイコンをフッターに置いてはいけません。
フッターは、デザイン上で置き場所のなかった機能の受け皿ではありません。フッターにはショートカット、ステータス、サーフェス全体に適用される操作を置けますが、各項目は次の問いに答えられなければなりません。対象は何か、なぜ今使えるのか、どの範囲に影響するのか、実行後に目に見えて何が変わるのか。
#モーション
モーションは変化を説明するものであり、環境的な装飾ではありません。表示、非表示、展開、空間的な連続性には短いトランジションを使います。opacity や transform で同じ関係を伝えられる場合は、大きなレイアウト変更をアニメーションさせるのを避けます。reduced-motion の設定(motion-reduce:、reduceMotion())に従い、状態を理解するためにアニメーションを必須にせず、すべてのコンポーネントに既定のアニメーションを付けることもしません。
view()
.on_press(open)
.transition_transform()
.duration(150.0)
.when(!reduce_motion(), |v| v.active(|s| s.scale(0.97)))<View onPress={open} class="transition-transform duration-150 active:scale-[0.97] motion-reduce:active:scale-100" />モーションのポリシーはアプリケーション層に属します。nsh/base は、トランジションに必要なライフサイクルの仕組みやジオメトリを担いますが、すべてのプロダクトがフェードやスライドを使うと決めるものではありません。個別にアニメーションする値には安定した ID を与え、中断された場合は、古い終点からやり直すのではなく、その時点でサンプリングされた現在の値から滑らかに逆方向へ動くようにします。
モーションは、操作の連続性に沿わせます。ポインターやスクロール位置が関連するターゲット間を移動している間は、プレビューのサーフェスを安定させたまま、内容や位置をその場で更新します。閉じるのは操作が終わった後だけにします。更新のたびにフェードアウトと再表示を繰り返すと、ちらつきに見えます。
#データ量の多いインターフェースの設計
密度が高いことは、窮屈であることを意味しません。テーブル、ツリー、長いリスト、エディターでは次のようにします。
- ヘッダーと、行の主要な識別情報を視覚的に安定させる。
- 比較する値は揃え、適切な場合は等幅数字(tabular numerals)を使う。
- フォーカス、ホバー、アクティブな行、複数選択を区別する。
- ソートとフィルターは常に見えるようにし、元に戻せるようにする。
- フィルターや並べ替えをまたいで、ドメインの識別子で選択状態を保持する。
- 大規模なコレクションは仮想化する(
List)。その際、キーボードのセマンティクスは変えない。 - 二次的な列やインスペクターには、段階的な開示(progressive disclosure)を使う。
- 次に取るべき操作を説明する、役立つ空の状態を用意する。
一貫したフィールドを横断して比較する場合はテーブル、性質の異なる項目をざっと見る場合はリスト、本当に階層がある場合はツリーを選びます。複雑なデータレイアウトを、見た目のスタイルとして使ってはいけません。
#インターフェースの言葉
言葉は、インターフェースのアーキテクチャの一部です。機能の語彙は、実装上の機能を個別に訳したものではなく、遷移先、オブジェクト、コマンド、状態、結果からなる体系として書きます。実際の文脈で正確さを保てる範囲で、最も短い表現を選びます。
#文脈に文脈を担わせる
周囲のサーフェスがすでに示している情報を繰り返してはいけません。サイドバーの遷移先は、たいていオブジェクトやドメインそのものです。User Management ではなく Users、Shortcut Configuration Management ではなく Shortcuts を使います。各行にすでに操作が含まれている列では、汎用的な Operation という見出しを省略できます。Delete “Roadmap”? というタイトルの dialog では、同じ問いを本文で繰り返す必要はありません。
これは文脈による省略であり、削ること自体が目的ではありません。判断を左右する場合は、文章を足します。影響を受ける範囲、元に戻せない結果、通常とは異なる前提条件、復旧の方法などです。追加する言葉はすべて、現在のレイアウトがまだ答えていない問いに答えるものでなければなりません。
遷移先やオブジェクトには名詞(Users、Appearance、Orders)、コマンドには動詞(Save、Duplicate、Export)、状態には形容詞や短いフレーズ(Offline、Up to date、Pending review)を使います。Management、Module、Page、Function、Operation、System のようなラッパーとなる語は、実際のドメイン概念を区別する場合を除いて避けます。
#言語ごとに書く。形を翻訳しない
共通の意図、階層、用語から出発し、各ロケールで自然なインターフェースの言葉として組み立てます。原文の語順、語数、丁寧さのための冗長な語、文法上のカテゴリーをそのまま保ってはいけません。英語の Users は、直訳すると「ユーザー管理」に展開されてしまう中国語の機能概念を表現できます。忠実さとは、トークンを保つことではなく、目的を保つことです。
囲んでいる情報アーキテクチャが補ってくれる言葉は取り除きます。Settings サーフェスの中では、遷移先はたいてい単に Account であり、Account Settings ではなく、不自然な単数形の Account Setting でも決してありません。正しい英語のラベルは、単独の原文フレーズからではなく、その役割と隣り合う要素から選びます。
繰り返し登場するオブジェクト、コマンド、状態については、小さなプロダクト用語集を維持します。文脈によって意味が本当に変わる場合を除き、ツールバー、メニュー、コンテキストメニュー、dialog、ショートカット検索、ドキュメントで同じ用語を使います。文言は、実際に描画されたサーフェス上で確認します。隣り合うラベルを見ると、ロケールファイルだけでは気づけない繰り返しや、範囲の不整合が見えてくることがよくあります。
ローカライズされた技術文書では、翻訳すると精度が落ちる場合、定着しているフレームワーク用語を残します。API の識別子は元の形のまま保ち、コードとして書式設定します。技術的に見せるためだけに、普通の外来語を残してはいけません。残した用語は、必要に応じて初出時に説明し、その後はインターフェース、ドキュメント、API の例を通して同じ形を使います。
ドキュメントでは、名前付きの API、コンポーネント、ソースファイル、ガイドのリンクテキストに、ローカライズされたページでも正確な英語名を使います。たとえば View、Button、Menu、Text のように書き、名前を翻訳したり、冗長な NSH プレフィックスを付けたりしません。周囲の説明は、そのページの言語で書きます。リンクにするのは名前付きの対象だけで、「ソース」のように、なぜ引用するかを説明する言葉はリンクの外に置きます。
複数のロケールで共有される技術比較表では、機能名、フレームワーク名、API 名、モデル名を、定着している英語の形のまま残します。翻訳するのは表の周囲の説明であり、技術的なラベルではありません。ただし、比較表以外の文脈で、日本語として定着した名前のある一般的なインターフェース用語は、ローカライズして構いません。
#ボタンと確認ダイアログ
ボタンのラベルは、原則として短くします。通常は 1〜2 語で、ジェスチャーやコンポーネントではなく、結果を表します。
Click to save、Perform move、Confirm deletion よりも Save、Move、Delete を優先してください。確定せずに離れる操作には、一貫して Cancel を使います。OK は、純粋に情報を伝えるだけの内容を了承する場合に限って使います。
短さは原則であり、文字数の上限ではありません。結果を明示したり、ユーザーが取り違えやすい選択肢を区別したりできる場合は、意図的に長いラベルのほうが適しています。たとえば Delete from this group と Delete everywhere, or Restart without saving のような場合です。長さは、判断に不可欠な情報と引き換えにするものであり、ダイアログのタイトルや本文を繰り返すために使ってはいけません。
可能な限り、最も具体的で簡潔な結果を確認ラベルにします。
| コンテキスト | 避ける | 推奨 |
|---|---|---|
| 削除ダイアログ | Yes、Sure、Confirm deletion | Delete |
| 未保存の変更 | Confirm、Yes | Discard changes |
| 単なる了承 | Confirm operation | OK または Done |
| 結果に明確な動詞がない複雑な同意 | Yes | Confirm |
Confirm は、周囲のダイアログが複雑な確約の内容を十分に示しており、それより短く正確な結果の動詞がない場合のフォールバックとして有用です。明確なコマンドの代わりに使ってはいけません。Sure は会話的な表現であり、英語の安定したコマンドではなく、標準の語彙とするには曖昧すぎます。
確認ダイアログは、1 つのまとまった判断として構成します。
- タイトル: 判断または条件。たとえば
Delete “Roadmap”?。 - 本文: 新しい範囲、結果、復旧に関する情報のみ。
- アクション:
Cancelと、結果を表すラベル。たとえばDelete。 - 破壊的操作のスタイル: 正確な文言の代わりにするのではなく、破壊的な結果に適用します(
destructiveロールを持つAlertDialogアクション)。
実際の条件を示せる場合は、Notice、Warning、Error、Confirmation のような汎用的なタイトルを避けてください。
構造や状態がすでに意味を伝えている場合は、「本当に…しますか」「…されますか」「ご注意ください」「正常に」といった儀礼的な表現を避けます。礼儀正しさは、落ち着いた丁寧なトーンで表し、please を繰り返して示すものではありません。
#大文字小文字、句読点、記号
英語の UI では、原則として sentence case を使います。Reset layout とし、Reset Layout や RESET LAYOUT にはしません。固有名詞や定着した頭字語は保持してください。ネイティブメニューコマンドに title case を使うといったプラットフォームの慣習には、プラットフォーム統合の面で利点がある場合にのみ従い、同じコンポーネントクラス内では一貫して適用します。
すべて大文字(ALL CAPS)は、ごく短いセクションラベル、アイブロー、ステータス、定着した頭字語、コードのような識別子に対して、控えめなタイポグラフィ上の強調として使えます。コンパクトな字形と適度な字間は、太字に近い強さを生み出せますが、ボタン、長い見出し、文章、密度の高いリストには適しません。大文字、強い色、太字を同じ領域で組み合わせてはいけません。また、すべての文字列を自動的に変換してはいけません。製品名、頭字語、ローカライズされたコンテンツは、本来の大文字小文字を保持する必要があります。
ラベル、ボタン、メニュー項目、タブ、見出し、プレースホルダー、短い状態表示の末尾にはピリオドを付けません。完結した説明文、警告文、エラー文には付けます。通常の成功・失敗メッセージでは感嘆符を避けてください。中国語では、文には全角の句読点を使い、短いコントロールラベルには同じ意味上の規則で文末の句読点を付けません。
省略記号には、ピリオド 3 つではなく、単一の省略記号文字(…)を使います。ダイアログ、シート、別ウィンドウを開くすべてのボタンまたはメニュー項目、および完了前に追加の入力や選択が必要なコマンド(Settings… や Export… など)に付けます。即座に実行されるコマンドには省略記号を付けません。進行中の処理を示すには、装飾的なドットではなく、不確定のプログレスインジケーターを使います。
エラーでは、何が起きたかを述べ、有用な場合は次の復旧操作も示します。成功のフィードバックは、その結果の状態がまだ見えていない場合にのみ、その状態を示します。技術的なコードや長い謝罪よりも Couldn’t save. Check your connection and try again. を優先してください。ドキュメントが保存済みの状態に目に見えて変わる場合は、Saved successfully トーストを表示しません。
#国際化とプラットフォームへの適合
アクセシビリティ(A11y)の名前とラベルは英語のままでもかまいません。これらの文字列にローカライズは必須ではありません。あるコントロールが対象に作用する場合、そのコントロールを示すときは、対象自身のラベルをそのまま使います。ラベルがローカライズされている場合も同様です。この例外は、画面に表示される UI のコピーには適用されません。
コピーは、文字列の伸長、CJK のタイポグラフィ、右から左へ書く文字体系、異なるショートカット表記に耐えられる必要があります。1 つの英語ラベルからコントロールのサイズを決めてはいけません。テキストをラスター画像に含めず、翻訳済みの断片を連結することを避け、ツールチップなど製品が復旧手段を定めている場合にのみ、ラベルの折り返しや切り詰めを許可します。
意味を持つプラットフォーム間の違いを尊重してください。Command と Control、ネイティブのウィンドウ装飾、システムの外観、スクロールバーの挙動、メニュー、通知機能、そしてスマートフォンではシステムヘッダー、戻るジェスチャー、アラートなどです。製品の情報設計はプラットフォーム間で安定させますが、表面的なピクセルの一致のために、慣れ親しんだプラットフォームの挙動を損なってはいけません。
NSH アプリは、スマートフォン、タブレット、デスクトップ、Web で動作し、多くの場合ホストアプリの内部でも動作します。それぞれを想定して設計してください。
- システム UI はプラットフォームの見た目を保つ。 iOS と Android では、ページのヘッダーはシステムのものです(
NavPageのtitleとヘッダーオプション)。デスクトップと Web では、アプリが描画するページのheaderスロットが表示されます。すべてのページに両方を用意してください(ナビゲーション)。確認にはAlertDialogを使い、ステータスバーは最前面のページまたはダイアログに従います(statusBar)。 - ホストのテーマ。 組み込みアプリにはホストのテーマを渡せます。トークンを読み取るクラスと Rust バインディングは、そのテーマにネイティブに従います(テーマトークン)。
- セーフエリアとキーボード。 コンテンツはセーフエリア内に収め(
env(safe-area-inset-*)、safeAreaInsets())、入力欄はソフトウェアキーボードの上に配置します(env(keyboard-inset-height))(デバイスと環境)。 - 外観、テキストサイズ、モーション。 すべてのサーフェスは、ライトとダークの両方(
dark:、colorScheme())、ユーザーのテキストサイズ、そしてモーションの軽減(motion-reduce:、reduceMotion())で機能する必要があります。 - 右から左。 レイアウトの方向はロケールに従い、
directionスタイルはビューとそのサブツリーに対して方向を設定します。start と end のプロパティは反転します(右から左)。 - タッチとポインター。 タッチターゲットは少なくとも 44 × 44 ポイントとし、ホバーとツールチップはポインターがある場合にのみ存在します。
use nsh_base::nav_page;
nav_page()
.title("Inbox")
.header(page_header("Inbox"))
.child(inbox())import { NavPage } from "nsh/base";
<NavPage title="Inbox" header={<PageHeader title="Inbox" />}>
<Inbox />
</NavPage>#AI 生成インターフェースのガイダンス
NSH のインターフェースを変更する AI は、まず最も近いフィーチャー、アプリのコンポーネントとテーマトークン、そしてコンポーネントのドキュメントと宣言(.nsh/types)を確認する必要があります。コードを生成する前に、主要なタスク、状態の所有者、コンポーネントの構成、キーボードとタッチの操作経路を明示してください。React、React Native、Shadcn の例から API を推測したり、名前がもっともらしいという理由だけで NSH の prop やメソッドを作り出したりしてはいけません。存在しないものは、nsh check とビルドのクラスチェックが報告します。
AI の出力は、階層、密度、コンポーネントの選択、例外的なリテラル値がなぜこの製品に適しているかを、人間が説明できて初めて完成となります。見た目にもっともらしいスクリーンショットは証明になりません。キーボードとタッチの挙動、フォーカス、動的コンテンツ、テーマ、リサイズ、テキストサイズ、失敗時の状態も、デザインの一部です。
#アクセシビリティチェックリスト
画面を完成とみなす前に、次の点を確認してください。
- すべてのアクションが、キーボード、タッチ、スクリーンリーダーで到達・操作できる。
- フォーカス順序が、視覚上の順序とタスクの順序に従っている。
- フォーカスが見える状態を保ち、オーバーレイの後に復元される。
- コントロールにアクセシブルな名前がある。アイコンのみのコントロールは、意味、範囲、ショートカットの説明が必要な場合にツールチップを使う。
- テキストと意味のある境界に十分なコントラストがある。
- 状態が色だけで伝えられていない。
- 無効状態と読み取り専用状態が区別できる。
- ラベル、エラー、説明が、対応するコントロールの近くにある。
- 長い翻訳、右から左、大きなテキストでもコンテンツが使える。
- タッチターゲットが少なくとも 44 × 44 ポイントであり、密度の高いレイアウトでもポインターターゲットが快適なサイズである。
#デザインレビューチェックリスト
レビューはコンポーネントの棚卸しではなく、インターフェースが正しい判断をしたかどうかを評価するものです。次の順に問いかけてください。
- タスクは明確か? 新しいユーザーが、学習や推測、試行錯誤をせずに、目的、主要なアクション、次のステップを認識できるか。
- すべてのアクションは約束を守っているか? ラベル、コントロール、状態、範囲、フィードバック、結果が、1 つの一貫した成果を表しているか。
- 階層は決定的かつ抑制されているか? 中核となる機能が相応のスペースを得ている一方で、強い色、太字、バッジ、アラート、主要ボタンが控えめに保たれているか。
- より少なく、より良くできないか? 完全なタスクを損なわずに、エントリーポイント、オプション、状態を削除、統合、または後回しにできないか。
- 構造は正確か? 同列の要素が配置の基準線を共有し、等しい間隔が描画されたピクセル単位まで等しく、スクロールバーが実際のスクロール領域の端にあるか。
- コンポーネントシステムに従っているか? 標準コントロールがジオメトリ、状態、キーボードの挙動、閉じ方のモデルを保ち、外観がテーマとスケールのトークンで与えられているか。
- あらゆる状態と制約の下で使えるか? キーボード、タッチ、フォーカスの挙動、空・読み込み中・失敗・権限の状態、長い翻訳、大きなテキスト、右から左、両方の外観、最小ウィンドウサイズ、モーションの軽減を確認する。
- 実際のプラットフォームでテストされているか? 理想的なスクリーンショットだけでなく、出荷先の各プラットフォームで、実際のコンポーネント、コピー、代表的なコンテンツを使ってタスクを完了する(
nsh run --target …、nsh test --target …)。