web-design について
web-designは、ウェブインターフェースの構築、レビュー、改善に使う実践的なリファレンススキルです。構造からビジュアル、インタラクション、アクセシビリティ、仕上げまで、段階別に指針をまとめています。
主な機能
- 01段階別の実装優先順位
- 02レスポンシブなレイアウトと余白の指針
- 03タイポグラフィと配色の原則
- 04アクセシビリティと状態の確認
- 05モーション・性能・CSSの提案
活用シーン
- 01ウェブページのレイアウト設計
- 02レスポンシブ表示のレビュー
- 03アクセシビリティと状態の点検
- 04モーションや見た目の仕上げ
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ pascalorg/ skills --skill web-designインストール後、Claude Code で/web-designを入力して読み込まれたか確認します。
詳細
Web デザイン
Web インターフェースを適切に構築するための、実践者の知見に基づくリファレンスです。Refactoring UI、Tailwind CSS、shadcn/ui、Laws of UX、animations.dev、detail.design、Every Layout、Web Interface Guidelines、jakub.kr、userinterface.wiki(Raphael Salaja)など、信頼できる情報源をまとめています。
Web インターフェースを構築、レビュー、改善するときは、いつでもこのスキルを使用してください。
実装の優先順位
UI を構築またはレビューするときは、以下の段階を順番に進めてください。各段階は上の段階を前提としています。レイアウトが崩れているのに、影の細部を直しても意味がありません。
Tier 1:構造(まずここを正しくする)
- セマンティック HTML。 正しい要素(
<button>、<nav>、<main>、順序どおりの見出し)を使います。それ以外のすべては、ここを土台にします。 - レイアウト。 グリッド/フレックスの構造、スペーシングのスケール、コンテンツ幅の制約(
max-w-prose、12 カラムグリッド)を整えます。どのビューポートでもページ全体が破綻していませんか? - レスポンシブな動作。 モバイルファーストで、固有サイズ設定(
auto-fillグリッド、clamp()、flex-wrap)を活用します。理由もなく、小さな画面でコンテンツを隠してはいけません。 - タイポグラフィの基本。 文字サイズのスケール、行の高さ(本文は 1.5、見出しは 1.1~1.25)、行の長さ(
max-width: 65ch)、フォントサイズへのrem単位の使用を整えます。
Tier 2:ビジュアルシステム(デザインの骨格)
- 色とコントラスト。 カラーパレットを適用し、セマンティックトークンを設定して、WCAG AA のコントラスト基準(テキストは 4.5:1、UI は 3:1)を満たします。必要に応じてダークモードにも対応します。
- 視覚的な階層。 4 つのテキストレベル(foreground、muted、muted/70、muted/50)を機能させます。主要なアクションがひと目でわかります。目を細めて見るテストにも合格します。
- コンポーネントの状態。 すべてのインタラクティブ要素について、ホバー、フォーカス可視、アクティブ、無効、読み込み中、エラー、空の各状態を考慮します。
- スペーシングと比率。 外側のパディングは内側のパディング以上にします。曖昧な余白はなくします。ボタンのパディング比率は横 2 倍:縦 1 倍にします。入力欄の高さはボタンの高さに合わせます。
Tier 3:インタラクションとモーション(生き生きとした操作感にする)
- キーボード操作とアクセシビリティ。 フォーカススタイル、タブ順序、スキップリンク、
aria属性、タッチターゲット(44px 以上)を整えます。キーボードだけでテストします。 - トランジション。 ホバー/アクティブ時のフィードバック(150ms ease-out)、状態のトランジション(200~300ms)、動く方向に合ったイージング(入場は ease-out、退場は ease-in)を設定します。
- アニメーション。 入場時は段階的に表示し、退場は控えめにし、中断可能なトランジションを使い、
prefers-reduced-motionを尊重します。適切な場合はスプリング物理を使用します。
Tier 4:仕上げ(作り込みを感じさせる最後の 10%)
- シャドウと奥行き。 レイヤー化したシャドウを使い、適切な場合はボーダーの代わりにシャドウを使い、光源の向きを統一します。ダークモードでは、シャドウではなくサーフェスの明るさを使います。
- 光学的な調整。 アイコン側のボタンのパディング、同心円状のボーダー半径、再生ボタンのオフセット、データへの tabular-nums、画像のアウトラインを調整します。
- マイクロインタラクション。 状況に応じたアイコンアニメーション、段階的な入場時のぼかし、クリップボードへのコピー時のフィードバック、楽観的更新を取り入れます。
- 防御的な CSS。 フレックスの子要素には
min-width: 0を設定し、overflow-wrap: break-word、scrollbar-gutter: stable、テキストの省略表示、安全領域のパディングを使用します。 - 最終確認。 目を細めて見るテスト、グレースケールテスト、入れ替えテスト、「人間がこれを出荷するだろうか?」テストを行います。AI っぽい雑な仕上がり(不必要なグラデーション、同じ見た目の指標カード、何でも中央揃え)は避けます。
経験則: 768px でレイアウトが崩れているのに、シャドウの不透明度を悩んでいるなら、いったん作業を止めて Tier 1 に戻ってください。
目次
- レイアウトとスペーシング
- タイポグラフィ
- 色
- シャドウと奥行き
- 視覚的な階層
- アニメーションと動き
- コンポーネント
- レスポンシブデザイン
- アクセシビリティ
- パフォーマンス
- UX 心理学
- デザイントークン
- 仕上げと技巧
- マイクロコピーと UX ライティング
- AI スロップの防止
- 高度な技巧
- 防御的 CSS
- モダン CSS リセット
- 予測プリフェッチ
- オーディオフィードバックとサウンドデザイン
1. レイアウトとスペーシング
スペーシングスケール
基本単位を基準とした、一貫性のある数学的なスケールを使用します。標準の基本単位は 4px(0.25rem)です。すべてのスペーシング値は、この基本単位の倍数にします。
| トークン | 値 | 用途 |
|---|---|---|
| 0.5 | 2px | ヘアライン状の隙間、アイコンのパディング |
| 1 | 4px | インライン要素間の狭いスペーシング |
| 1.5 | 6px | 小さなコンポーネントの内側のパディング |
| 2 | 8px | 関連項目間の標準的な間隔 |
| 3 | 12px | コンパクトなカードのパディング |
| 4 | 16px | 標準的なカード/セクションのパディング |
| 5 | 20px | ゆとりのあるパディング |
| 6 | 24px | セクションのパディング |
| 8 | 32px | セクション間の間隔 |
| 10 | 40px | 大きなセクション間の間隔 |
| 12 | 48px | ページ内セクションのスペーシング |
| 16 | 64px | ページの大きな区切り |
| 20 | 80px | ヒーローのスペーシング |
| 24 | 96px | 大きなヒーローのスペーシング |
スペーシングのルール
- 外側のパディング >= 内側のパディング。 カードの外側のマージンは、内側のパディングと同じか、それ以上にする必要があります。内側の要素同士は、外側の要素よりも密接に関連しています。
- 近接性は関連性を示します。 近くにある要素は関連していると認識されます(ゲシュタルトの近接の法則)。スペーシングを意図的に使い、グループ化したり分離したりします。
- 曖昧なスペーシングをなくします。 2 つの要素間の隙間が、どちらか一方に属するのか判断できない場合、それは曖昧です。非対称なスペーシングで関係性を明確にします。
- 使われない領域をなくします。 コンテナにマージンを設定するのではなく、子要素にパディングを設定します。インタラクティブな項目間のすべてのピクセルをクリック可能にします。
- 一貫した増分を使用します。 任意の値を選ばず、スケールに合わせます。似たような 3 つのスペーシング(14px、16px、18px)は間違いに見えます。1 つに絞りましょう。
レイアウトのプリミティブ
以下の CSS パターンを使うと、メディアクエリなしでレスポンシブなレイアウトを作成できます。
Stack -- 一貫した間隔を持つ垂直方向のフロー:
.stack > * + * { margin-block-start: var(--space); }
Cluster -- 隙間を保ちながら水平方向に折り返すレイアウト:
.cluster { display: flex; flex-wrap: wrap; gap: var(--space); }
Sidebar -- 片方の列を固定した 2 列レイアウト:
.sidebar { display: flex; flex-wrap: wrap; gap: var(--space); }
.sidebar > :first-child { flex-basis: 20rem; flex-grow: 1; }
.sidebar > :last-child { flex-basis: 0; flex-grow: 999; min-inline-size: 60%; }
Grid (auto-fill) -- ブレークポイントを使わないレスポンシブな列:
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); gap: var(--space); }
Center -- 幅を制限して中央揃えにするレイアウト:
.center { max-inline-size: var(--measure); margin-inline: auto; padding-inline: var(--space); }
グリッドと Flexbox の指針
- 2 次元レイアウト(行と列の組み合わせ)には CSS Grid を使用します。
- 1 次元レイアウト(単一の行または列)には Flexbox を使用します。
- Grid ではパーセンテージよりも
fr単位を優先します。frは隙間を差し引いた後のスペースを分配するため、オーバーフローを防げます。 - 項目間のマージンの代わりに
gapを使用します。 - 12 列グリッドを使うと、柔軟性を最大限に高められます(1、2、3、4、6 で割り切れます)。
- コンテンツのオーバーフローを防ぐため、Flex の子要素に
min-width: 0を設定します。 - Grid レイアウト内のスティッキーなサイドバー要素には
align-self: startを使用します。
実践例:Tailwind のレイアウトパターン
サイドバー付きのページシェル:
<div className="flex min-h-screen">
<aside className="hidden lg:flex w-64 flex-col border-r bg-muted/40 p-4">
<nav className="flex flex-col gap-1">{/* nav items */}</nav>
</aside>
<main className="flex-1 p-6">
<div className="mx-auto max-w-4xl space-y-8">{children}</div>
</main>
</div>
ブレークポイントなしで適応するカードグリッド:
<div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-4">
{items.map(item => <Card key={item.id} {...item} />)}
</div>
垂直方向のリズムを揃えたコンテンツセクション:
<section className="space-y-6 py-12">
<h2 className="text-2xl font-semibold tracking-tight">Features</h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{features.map(f => <FeatureCard key={f.id} {...f} />)}
</div>
</section>
適切なスペーシング階層(外側 > 内側)を持つカード:
<div className="rounded-lg border bg-card p-6"> {/* outer: p-6 */}
<div className="space-y-4"> {/* inner gap: 4 */}
<h3 className="font-semibold leading-tight">Title</h3>
<p className="text-sm text-muted-foreground">Description text here.</p>
</div>
</div>
2. タイポグラフィ
文字サイズのスケール
数学的な比率を使って、調和のとれたフォントサイズを生成します。用途に応じて比率を選んでください。
| 比率 | 名称 | 最適な用途 |
|---|---|---|
| 1.067 | マイナーセカンド | 密度の高い UI、ダッシュボード |
| 1.125 | メジャーセカンド | コンパクトなインターフェース |
| 1.200 | マイナートサード | 汎用(推奨のデフォルト) |
| 1.250 | メジャーサード | コンテンツが多いサイト |
| 1.333 | 完全四度 | マーケティング、編集コンテンツ |
| 1.414 | 増四度 | 大胆なプレゼンテーション |
| 1.500 | 完全五度 | 印象的な階層構造 |
| 1.618 | 黄金比 | インパクトを重視したランディングページ |
フォントサイズのルール
- 本文の最小サイズ:16px(1rem)。 これはブラウザーのデフォルトです。主要な読解コンテンツでこれより小さいサイズは使わないでください。
- フォントサイズには
remを使います。 ブラウザーのズームと、ユーザーが設定したデフォルトのフォントサイズの両方に対応できます。ピクセルを使うと、ユーザーがテキストを拡大できなくなります。 - メディアクエリには
remを使います。 ユーザーがデフォルトのフォントサイズを大きくすると、remベースのブレークポイントにより、より広い画面でもモバイルレイアウトが適用され、表示領域に余裕が生まれます。 - パディング、ボーダー、装飾要素には
pxを使います。 これらはテキストサイズに合わせて拡大縮小する必要がありません。 - 垂直方向のマージンには
remを使います。 段落間の間隔もテキストに合わせて広がります。
流動的なタイポグラフィ
ブレークポイント間で滑らかに拡大縮小するレスポンシブなサイズ設定には、clamp() を使います。
/* Heading: 32px on small screens, 48px on large, fluid between */
font-size: clamp(2rem, 1.5rem + 2vw, 3rem);
小画面向けのスケール(例:320px で 1.2 倍)と、大画面向けのスケール(例:1500px で 1.333 倍)を定義します。ブラウザーがその間を補間します。
行の高さ
- 本文:1.5。 WCAG の基準を満たし、すべてのユーザーにとって読みやすくなります。
- 見出し:1.1〜1.25。 大きなテキストほど行間を詰める必要があります。
- テキストが大きいほど、必要な行の高さは小さくなります。 反比例するように調整します。
- 見出しとボタンには、1.1 が適切なデフォルトです。
行の長さ
- 最適な長さ:60〜75 文字(1 行あたり約 8〜10 語)。
- 最大:80 文字。 これを超えると、読者は読んでいる位置を見失います。
- コンテンツコンテナーに
max-width: 65chを使い、この長さを適用します。
字間
- 大きなテキスト:字間を狭めます。 大きなテキストでは、文字間のスペースを小さくする必要があります。
- 小さなテキスト:字間を少し広げます。
- すべて大文字のテキスト:字間に +0.05〜+0.1em を加えます。 大文字は、余分なスペースがないと文字同士が詰まって見えます。
- 表やタイマーでは
font-variant-numeric: tabular-numsを使い、数字の幅を一定に保ちます。
テキストの折り返し
/* Balanced line breaks for headings */
h1, h2, h3, h4, h5, h6 { text-wrap: balance; }
/* Avoid orphans in paragraphs */
p { text-wrap: pretty; }
/* Prevent overflow from long words/URLs */
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
フォントのレンダリング
body { -webkit-font-smoothing: antialiased; }
html { text-rendering: optimizeLegibility; -webkit-text-size-adjust: 100%; }
フォントの太さ
- 400 未満のウェイトは使わないでください。ほとんどの画面で判読しにくくなります。
- 中程度の見出しには 500〜600 を使います。
- 強調には 700 を使います。
- ウェイトの変更によるレイアウトシフトを防ぎます: 太字のテキストを含む非表示の
::after疑似要素を使って太字時の幅を確保し、アクティブ状態の切り替え時に表示が揺れるのを防ぎます。
OpenType 機能
最新のフォントには、タイポグラフィの品質を大幅に向上させる OpenType 機能が搭載されています。意図的に有効化しましょう。
| 機能 | CSS | 用途 |
|---|---|---|
| 等幅数字 | font-variant-numeric: tabular-nums | テーブル、ダッシュボード、料金表示、タイマー — 同じ幅の数字が列に沿って揃います |
| オールドスタイル数字 | font-variant-numeric: oldstyle-nums | 本文・文章 — 上下に伸びる数字が小文字に馴染みます |
| スラッシュ付きゼロ | font-variant-numeric: slashed-zero | コードに近い UI、ID、エラーコード — 0 と O を区別しやすくします |
| 分数 | font-variant-numeric: diagonal-fractions | レシピ、仕様 — 1/2 をタイポグラフィ上の分数に変換します |
| 文脈依存の異体字 | font-feature-settings: "calt" 1 | 通常はデフォルトで有効です — グリフのスマートな調整のため、有効のままにします |
| 識別用セット | font-feature-settings: "ss02" | コード UI — I/l/1 と 0/O を区別します(フォントによって異なります) |
/* Data display: aligned, disambiguated */
.data { font-variant-numeric: tabular-nums slashed-zero; }
/* Prose: numbers that blend with text */
.prose { font-variant-numeric: oldstyle-nums; }
/* Code-adjacent UI */
.code-ui { font-variant-numeric: tabular-nums slashed-zero; font-feature-settings: "ss02"; }
font-optical-sizing: auto— デフォルトのままにします。フォントはサイズに応じてグリフの形状を調整します(小さいサイズでは線を太く、大きいサイズでは細部を繊細にします)。font-synthesis: none— フォントに該当するウェイトがない場合に、ブラウザーが擬似的な太字・斜体を生成しないよう無効化します。適切なフォントファイルを読み込む必要があります。- 可変フォントは、標準的な段階だけでなく、100〜900 の任意のウェイトを指定できます。 450 や 550 のような精密な値を使えば、追加のファイルを読み込まずに、階層を細かく調整できます。
text-decoration-skip-ink: autoはデフォルトで、下に伸びる文字部分を適切に避けます。text-underline-offset: 3pxを追加すると、下線を文字の下に配置でき、リンクがすっきりします。
書体の組み合わせ
- 書体は 2 種類までにします。 見出し用と本文用をそれぞれ 1 種類にします。2 つ目の書体は、デザインコンセプトを補強するものにします。
- コード用に、3 種類目の等幅書体を使うのは問題ありません。
- 1 種類の書体を使う場合は、ウェイト、サイズ、色で階層を区別します。
実践例:Next.js + Tailwind Typography
app/layout.tsx でのフォント設定:
import { Inter, JetBrains_Mono } from "next/font/google"
const sans = Inter({ subsets: ["latin"], variable: "--font-sans" })
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" })
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${sans.variable} ${mono.variable}`}>
<body className="font-sans antialiased">{children}</body>
</html>
)
}
使用中の Tailwind の文字サイズスケール(shadcn の規約):
{/* Page heading */}
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">Dashboard</h1>
{/* Section heading */}
<h2 className="text-2xl font-semibold tracking-tight">Recent Activity</h2>
{/* Card title */}
<h3 className="font-semibold leading-tight">Monthly Revenue</h3>
{/* Body text with constrained width */}
<p className="max-w-prose text-muted-foreground">
Your revenue increased 12% compared to last month.
</p>
{/* Small label */}
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Status
</span>
{/* Tabular numbers in a data display */}
<td className="tabular-nums text-right font-medium">$12,450.00</td>
3. 色
カラーシステムの構成
実用的なパレットには、次の 3 つのカテゴリーが必要です。
- ニュートラル(グレー): 8〜10 段階。テキスト、背景、パネル、フォームコントロールなど、ほぼあらゆる箇所に使用します。
- プライマリカラー: 主要なアクション、ナビゲーション、ブランディング用に 1〜2 色。それぞれ 5〜10 段階にします。
- アクセントカラー/セマンティックカラー: 赤(破壊的な操作/エラー)、黄/アンバー(警告)、緑/エメラルド(成功)、青(情報)。それぞれ 5〜10 段階にします。
9 段階のシステム
各色について、50〜900 の番号を付けた 9 段階を定義します。
| 段階 | 用途 |
|---|---|
| 50 | ほのかに色味を加えた背景 |
| 100 | ホバー時の背景、薄い塗り |
| 200 | アクティブ時の背景、バッジ |
| 300 | ボーダー、リング |
| 400 | プレースホルダーテキスト、無効状態 |
| 500 | プライマリボタン、アイコン、リンク |
| 600 | プライマリボタンのホバー時 |
| 700 | アクティブ/押下状態 |
| 800 | 明るい背景上の見出しテキスト |
| 900 | 明るい背景上の本文テキスト |
| 950 | 高いコントラストを実現する最も暗い色 |
色の形式
- 主な色の形式には OKLCH を使用します。知覚的に均一で(同じ明度の色は、実際に同じ明るさに見えます)、最新のすべてのブラウザーでサポートされ、Tailwind v4 と shadcn/ui でも使われています。形式:
oklch(lightness chroma hue)。 - HSL は、OKLCH のツールが利用できない場合に有効な代替形式です。直感的に扱え、明度を調整して色の濃淡を作り、彩度を調整して色味を変えられます。
- 柔軟性を最大限に高めるため、色チャンネルを CSS 変数として保存します。
:root {
/* OKLCH approach (preferred) */
--primary: oklch(62% 0.21 260);
/* HSL approach (alternative) */
--primary-h: 221;
--primary-s: 72%;
--primary-l: 62%;
--primary-hsl: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
}
色のルール
- 純色ではなく、ほぼ黒とほぼ白を使います。 純粋な黒(#000)と純粋な白(#fff)の組み合わせは、コントラストが強すぎて目に負担がかかります。代わりに
#0a0a0a/#111と#fafafa/#f5f5f5を使います。 - ニュートラルカラーに色味を加えます。 グレーの色調にメインカラーをわずかに加えます(HSBで彩度は5%未満に保ちます)。これにより、パレットに統一感が生まれます。
- 色温度を統一します。 暖色系または寒色系の色味を帯びたニュートラルカラーのどちらか一方を使い、混在させないでください。
- 色は色相だけでなく、明るさも変えます。 明度が同じ2色は、見た目の上で競合します。明るさを変えて違いを出します。
- 鮮やかなバリエーションを作るときは色相を回転させます。 明るい色合いを作る場合は、より明るい基準色(黄色、シアン)に向かって色相を回転させます。暗い色合いを作る場合は、より深い基準色(青、紫)に向かって回転させます。これにより、色あせたパステルカラーになるのを防げます。
- 意味を伝える手段として色だけに頼らないでください。 アクセシビリティのため、必ずテキスト、アイコン、またはパターンを組み合わせます。
- 動的なバリエーションには
color-mix()を使います。
background: color-mix(in oklch, var(--primary), transparent 95%);
ダークモード
- 明るい値と暗い値を入れ替えます(900を前景色、50を背景色にします)。
- 単純に反転するだけではいけません。ダークな背景ではコントラストを抑える必要があります。コンテナの明るさと背景の差は最大12%にします。
- ダークな背景に純白のテキストを使わないでください。ほぼ白の色(例:
#e5e5e5)を使います。 - ダークモードでは影は効果がありません。代わりに、控えめな境界線や、明るさを上げた浮き上がり面を使います。
- デフォルトでは
prefers-color-schemeに従います。特に必要な場合を除き、テーマ切り替えを追加しないでください。
実践例:shadcn/ui のテーマ設定
app/globals.css — セマンティックカラートークン(shadcn/ui のパターン):
@layer base {
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.965 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.965 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.965 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--border: oklch(0.269 0 0);
}
}
next-themes を使ったダークモード切り替え(ユーザーが手動で操作する必要がある場合のみ追加します。デフォルトではシステム設定に従います):
// app/layout.tsx
import { ThemeProvider } from "next-themes"
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
)
}
// components/theme-toggle.tsx
"use client"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import { Moon, Sun } from "lucide-react"
export function ThemeToggle() {
const { setTheme, theme } = useTheme()
return (
<Button variant="ghost" size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-[transform,opacity] dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-[transform,opacity] dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}
4. 影と奥行き
影の原則
- 光源を一貫させます。 すべての影で同じ角度比を使います(通常は上方やや左)。ページ内のすべての影で、同じオフセット比を使用します。
- 高さに応じて影を調整します。 要素が浮き上がるほど、オフセットを大きくし、ぼかしを強くし、不透明度を下げます。これにより、物理的な光の振る舞いを再現できます。
- 複数の影を重ねてリアルにします。 単一の
box-shadowは平坦に見えます。幾何学的に値を大きくしながら、2~5層を重ねます。(読みやすさのため、影の色には以下のHSLを使っていますが、どの色形式でも構いません。)
/* Low elevation */
box-shadow: 0 1px 2px hsl(var(--shadow-color) / 0.3);
/* Medium elevation */
box-shadow:
0 1px 2px hsl(var(--shadow-color) / 0.2),
0 2px 4px hsl(var(--shadow-color) / 0.2),
0 4px 8px hsl(var(--shadow-color) / 0.2);
/* High elevation */
box-shadow:
0 1px 2px hsl(var(--shadow-color) / 0.12),
0 2px 4px hsl(var(--shadow-color) / 0.12),
0 4px 8px hsl(var(--shadow-color) / 0.12),
0 8px 16px hsl(var(--shadow-color) / 0.12),
0 16px 32px hsl(var(--shadow-color) / 0.12);
- 影の色を周囲の環境に合わせます。 純粋な黒やグレーの影は使わず、背景の色相を加えて、鮮やかで自然な奥行きを表現します。
- 影のぼかしは影の距離の2倍にします。 縦方向のオフセットが4pxなら、ぼかしは8pxにします。
- ユーザーに近い要素ほど明るくします。 実世界の光を模倣し、光源に近い面を明るくします。
- ダークなインターフェースでは影を避けます。 視覚的な理屈に合わないためです。代わりに、控えめな境界線や、面の明度の違いを使います。
- 奥行きを表す手法を混在させないでください。 影、境界線、面の明度のいずれかを選び、一貫して使います。
- 複数の影を直接アニメーションさせないでください。 処理負荷が高すぎます。代わりに、疑似要素を使い、2つの影の状態間で
opacityをアニメーションさせます。
角丸
- 角の半径を適切に入れ子にします。 内側の半径は、外側の半径から要素間の隙間を引いた値にします。外側が12pxでパディングが8pxの場合、内側は4pxにします。
- コンテナの境界線には、2方向のコントラストが必要です。 コンテナの背景とページの背景の両方に対して、境界線を識別できるようにします。
境界線の代わりに影を使う
控えめな複数層の box-shadow は、実線の境界線よりも奥行きを加えやすく、透明度によってどのような背景にも適応します。
/* Multi-shadow composition — replaces border with soft depth */
box-shadow:
0px 0px 0px 1px rgba(0, 0, 0, 0.06),
0px 1px 2px -1px rgba(0, 0, 0, 0.06),
0px 2px 4px 0px rgba(0, 0, 0, 0.04);
/* Hover: slightly increase opacity for lift effect */
box-shadow:
0px 0px 0px 1px rgba(0, 0, 0, 0.08),
0px 1px 2px -1px rgba(0, 0, 0, 0.08),
0px 2px 4px 0px rgba(0, 0, 0, 0.06);
- 最初のレイヤー(
0px 0px 0px 1px)は境界線の代わりになります。ぼかしなしで広がりを持たせることで、くっきりとした1pxの縁になります。 - 画像背景や色付きの背景でも使用できます。実線の
border-colorでは背景とぶつかる場合があります。 - インタラクティブな要素に浮き上がりを加えるには、ホバー時に
box-shadowをトランジションさせます。transition: box-shadow 150ms ease(またはTailwindのtransition-shadow)を使います。
画像のアウトライン
画像に1pxのアウトラインを追加し、視覚的な枠取りと奥行きを統一します。
img {
outline: 1px solid rgba(0, 0, 0, 0.1);
outline-offset: -1px;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
img { outline-color: rgba(255, 255, 255, 0.1); }
}
インセットリングの手法よりもシンプルで、<img>に直接適用できます。outline-offset: -1pxを指定すると、アウトラインが画像の境界内に収まります。
実践:Tailwindのシャドウと奥行き
Tailwindのエレベーションクラスを用途に割り当てる:
{/* Flat surface — default cards */}
<div className="rounded-lg border bg-card shadow-sm">
{/* Raised — hover state, dropdown trigger */}
<div className="rounded-lg border bg-card shadow-md hover:shadow-lg transition-shadow">
{/* Floating — dropdowns, popovers, command palette */}
<div className="rounded-xl border bg-popover shadow-xl">
{/* Dialog/modal backdrop + elevated panel */}
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm">
<div className="rounded-xl border bg-card shadow-2xl">
境界線の代わりにシャドウを使う(ホバー時に浮き上がるカード):
<div className="rounded-lg bg-card p-4
shadow-[0px_0px_0px_1px_rgba(0,0,0,0.06),0px_1px_2px_-1px_rgba(0,0,0,0.06),0px_2px_4px_0px_rgba(0,0,0,0.04)]
hover:shadow-[0px_0px_0px_1px_rgba(0,0,0,0.08),0px_1px_2px_-1px_rgba(0,0,0,0.08),0px_2px_4px_0px_rgba(0,0,0,0.06)]
transition-shadow">
<h3 className="font-semibold">No border needed</h3>
<p className="text-sm text-muted-foreground">Shadow provides the edge.</p>
</div>
インセットアウトライン付きの画像(シンプルな方法):
<img
src={src} alt={alt}
className="rounded-lg outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
/>
入れ子の角丸(内側に画像があるカード):
{/* Outer: rounded-xl (12px), padding: p-2 (8px), so inner = 12-8 = 4px = rounded-sm */}
<div className="rounded-xl border bg-card p-2">
<img className="rounded-sm w-full" src={src} alt={alt} />
<div className="p-3">
<h3 className="font-semibold">Title</h3>
</div>
</div>
5. 視覚的な階層
基本原則
- サイズ、太さ、色で重要度を伝えます。 主要なコンテンツは大きく、太く、コントラストを高くします。補助的なコンテンツは小さく、細く、コントラストを低くします。
- 主要なコンテンツを過度に強調するのではなく、補助的なコンテンツを目立たなくします。 すべてを強く見せるより、補助要素を控えめにするほうが効果的です。
- 意味上の階層 != 視覚的な階層です。
<h2>を常に大きな見出しのように見せる必要はありません。HTMLタグではなく、レイアウト内でコンテンツが果たす役割に合わせてスタイルを設定します。 - ラベルへの依存を減らします。 文脈から意味が明らかな場合、ラベルは冗長です。「[email protected]」に「メール:」というラベルは不要です。形式そのものが意味を伝えます。
- 太さとコントラストを別々の調整手段として使います。 太くて濃いテキストと、通常の太さで灰色のテキストは、異なる階層レベルに位置します。サイズ、太さ、色を組み合わせることで、階層を独立して調整する3つの手段が得られます。
- 重要な要素には高いコントラストを、構造要素には低いコントラストを使います。 装飾や境界線ではなく、操作要素やコンテンツに注目を集めます。
視覚的な重みの順序
- 要素が並ぶ場合は、視覚的な重みが大きいものから小さいものへと配置します。最も重い要素は外側に置きます。
- 境界線は減らします。よりすっきりと区切るには、境界線を間隔、背景色の違い、シャドウに置き換えます。
- **アクセント境界線(3~5px)**をカードやコールアウトの一辺に加えると、強い区切りを作らずに視覚的な面白さと階層を加えられます。
整列
- すべての要素を何かに揃えます。 整列によって、要素同士の関係と配置の意図が伝わります。
- 数学的な整列より、視覚的な整列を優先します。 三角形の形状(再生ボタンや矢印)は、視覚的な中心と幾何学的な中心が異なります。目で見て判断します。
- コントラストの高い点の間隔を測ります。 目はバウンディングボックスではなく、コントラストを基準に要素の端を捉えます。
4段階のテキスト階層
すべてのインターフェースに必要なテキスト階層は、ちょうど4段階です。段階が多すぎると雑然とし、少なすぎると平坦になります。
{/* Level 1: Primary — the main content the user came to see */}
<p className="text-foreground">Invoice #1234 — $2,400.00</p>
{/* Level 2: Secondary — supporting info that adds context */}
<p className="text-muted-foreground">Due March 15, 2026</p>
{/* Level 3: Tertiary — metadata, timestamps, IDs */}
<span className="text-xs text-muted-foreground/70">Created 2 days ago</span>
{/* Level 4: Muted — disabled, placeholder, non-essential */}
<span className="text-xs text-muted-foreground/50">Optional</span>
サーフェスのエレベーション(ダークモードの奥行き)
ダークモードではシャドウが見えません。その代わりに、明度の変化で奥行きを作ります。エレベーションが高いレイヤーほど、明るさを数パーセント上げます。
{/* Level 0: Page background */}
<div className="bg-background"> {/* darkest */}
{/* Level 1: Card surface */}
<div className="bg-card"> {/* slightly lighter */}
{/* Level 2: Dropdown / popover on top of card */}
<div className="bg-popover"> {/* lighter still */}
</div>
</div>
</div>
各レベル間の明度差は控えめにします(OKLCHの明度で2~5%)。差が大きすぎると、インターフェースが互いにつながりのない「世界」に分断されます。
品質チェック
- 目を細めて確認します。 目を細めてページを見ます。見出しと本文、主要な操作と補助的な操作の違いなど、階層が見て取れるはずです。
- 入れ替えテストをします。 書体をシステムフォントに置き換えても、デザインは成り立ちますか? 成り立たない場合、階層は構造ではなくフォントに依存しています。
- グレースケールテストをします。 グレースケールに変換します。区別できていた要素が見分けられなくなる場合、色相だけに頼っています(アクセシビリティ上の問題です)。
6. アニメーションと動き
アニメーションを使う場面
- フィードバック: システムが操作(ボタンの押下、フォームの送信、切り替え)を認識したことを確認します。
- 状態遷移: インターフェースのモードや表示の変化を示します。
- 空間的なナビゲーション: 階層内の位置をユーザーが理解できるようにします(ズーム=奥行き、スライド=横方向の移動)。
- 操作の手がかり: 要素の操作方法(ドラッグの方向、スワイプ可能な領域)を示します。
アニメーションを使わない場面
- 高頻度のインタラクション。 1日に何度も行う操作は、アニメーションなしで即座に反応するようにします。
- キーボードで開始する操作。 即時に反応し、入力と連動しているように感じられる必要があります。
- パフォーマンスが低下する場合。 カクつくアニメーションは、アニメーションなしよりも悪影響があります。60fpsを維持できないなら使わないでください。
- 毎日使う反復的な要素。 1日を通して何度も目にするアニメーションは、煩わしくなります。
- 不必要な動き。 インタラクションを明確にしたり、機能を説明したりしない場合は、削除してください。
イージング関数
| イージング | 用途 |
|---|---|
ease-out | 画面に入る、表示される、展開する要素。デフォルトの選択肢です。 |
ease-in-out | 画面に表示されたまま移動する要素。 |
ease | ホバー効果、色の遷移。 |
linear | 継続するアニメーションやループするアニメーションのみ(スピナー、進捗バーの塗りつぶし)。 |
ease-in | 画面から退出する要素。画面に残る要素には決して使わないでください。 |
組み込みのCSSイージングより、カスタム曲線を強く推奨します。本番環境での曲線の例:cubic-bezier(0.32, 0.72, 0, 1)(iOSのシートの動作で使用。500ms)。
時間のガイドライン
| コンテキスト | 時間 |
|---|---|
| ホバー効果 | 100-150ms |
| ボタン押下のフィードバック | 約160ms |
| ツールチップの表示/終了 | 約125ms |
| ドロップダウン/選択メニューを開く | 約180ms |
| 一般的なUIの遷移 | 150-300ms |
| トースト通知 | 約400ms |
| ドロワー/シートを開く | 約500ms |
| 長押しで確定(破壊的操作) | 2000ms(安全性) |
| 長押し解除のフィードバック | 200ms |
- 安全性を考慮した非対称なタイミング: 破壊的な操作にはゆっくりと予測しやすいタイミングを使い、解除時のフィードバックは速くします。
- 表示と終了は同じではありません。 表示時と終了時のアニメーションを区別します。表示時には明確な印象を、終了時には自然な印象を与えます。
- 終了時のアニメーションは表示時より控えめにします。 終了時の移動量は少なくします。たとえば、全距離(
y: calc(-100% - 4px))ではなく固定の小さな値(例:y: -12px)を使います。終了する要素に必要なのは方向を示す程度の動きであり、完全に退場するアニメーションではありません。終了時に軽いblur(4px)を加えて、消え方を柔らかくします。
アニメーションの手法
押下時の拡大縮小: 即座に触覚的なフィードバックを与えるため、:activeでscale(0.97)を使います。
拡大率を0から始めないでください。 scale(0.9)以上から始めます。空気の抜けた風船でさえ、形は見えます。
起点を考慮したポップオーバー: 中央ではなく、transform-originを使ってトリガーの位置から拡大します。
ツールチップの遅延パターン: 最初のツールチップは遅延して表示します。ホバー中に続けて表示されるツールチップは、即座に表示します。
ぼかしで補う: イージングを調整しても違和感が残る場合は、遷移中にfilter: blur(2px)を加えます。
表示にはclip-pathを使います: ハードウェアアクセラレーションに対応し、レイアウトシフトがありません。clip-path: inset(0 0 100% 0)からinset(0 0 0 0)へ遷移させます。
アニメーションを中断可能にします。 CSSトランジションは最新の状態に向けて補間され、動作の途中で中断できます。ホバー、切り替え、ドラッグなどのインタラクションに使います。CSSキーフレームアニメーションは固定のタイムラインで実行され、途中で目標を変更できません。ページ読み込み時の表示や祝賀エフェクトなど、一度だけ再生する段階的なシーケンスに使います。ユーザーはインタラクションの途中で意図を変えるため、中断可能なアニメーションは応答性が高く感じられ、中断できないアニメーションは動作が壊れているように感じられます。
アイコンは状況に応じてアニメーションさせます。 アイコンが条件に応じて表示または非表示になる場合(例:コピーアイコンがチェックマークに置き換わる場合)は、瞬時に切り替えるのではなく、opacity、scale、blurをまとめてアニメーションさせ、滑らかに遷移させます。スプリング物理または短いトランジション(150-200ms)を使います。これは、状態に応じて入れ替わるあらゆる要素に当てはまります。
ステージングと振り付け(ディズニーの12原則に基づく)
- 一度に注目点は1つだけです。 目立つアニメーションを付ける要素は1つだけにします。2つの要素が同時に動くと、視線の向け先が定まりません。
- コンテキストメニューは表示時ではなく、終了時にのみアニメーションさせます。 即座に表示し、消えるときにアニメーションさせます。
- スクワッシュ&ストレッチの範囲:スケール0.95〜1.05。 これを超えると、UIでは漫画のように見えます。
- 項目ごとのずらし時間:最大50ms。 それ以上だともたついて感じられます。5項目のリストなら、0ms、50ms、100ms、150ms、200msです。
- 分割してずらし、インパクトを出します。 ブロック全体を一度にアニメーションさせるのではなく、小さなまとまり(セクション、行、単語など)に分割し、それぞれを個別にアニメーションさせます。初期状態には
opacity、blur(4-5px)、translateY(6-8px)を組み合わせます。CSSカスタムプロパティを使うと、ずらしをすっきり制御できます:animation-delay: calc(var(--stagger-delay, 80ms) * var(--stagger-index, 0))。粒度は3段階です:コンテナ単位(シンプル)、セクション単位(バランス型)、単語単位(ドラマチック)。 - オーバーレイの背景を暗くします。 暗くした背景によって、前面の要素に注目が集まります。
- 跳ねて収まる効果には、スプリング物理を使います。 イージングカーブでごまかさないでください。Framer Motionの
type: "spring"にstiffness: 300, damping: 20を指定するのが、よい出発点です。
安全にアニメーションできるプロパティ
transform(移動、回転、スケール)-- GPUで高速化されますopacity-- GPUで高速化されますclip-path-- GPUで高速化されます
アニメーションを避けるべきプロパティ
width、height、padding、margin-- レイアウトの再計算が発生しますtop、left、right、bottom-- レイアウトの再計算が発生しますbackground-color-- 再描画が発生します(コストが高くなります)
パフォーマンスの順位
- CSSアニメーション/トランジション(最良 -- メインスレッド以外で実行)
- Web Animations API(良好 -- メインスレッドの混雑時も動作を継続)
- JavaScriptの
requestAnimationFrame(最も低速 -- ブラウザーがビジー状態になると遅延)
アクセシビリティ
常にprefers-reduced-motionを尊重してください:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
実践例:Tailwind + Framer Motion
押下時のフィードバックを付けたボタン(CSSのみ、Tailwind)。 再生時間は約160msのガイドラインに合わせています(Tailwindで最も近い値:duration-150):
<button className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground
transition-[background-color,transform] duration-150 ease-out
hover:bg-primary/90
active:scale-[0.97]">
Save Changes
</button>
Framer Motion + shadcnによるアニメーション付きダイアログ:
"use client"
import { motion, AnimatePresence } from "motion/react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
export function AnimatedDialog({ open, onOpenChange, children }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<AnimatePresence>
{open && (
<DialogContent forceMount asChild>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: [0.32, 0.72, 0, 1] }}
>
{children}
</motion.div>
</DialogContent>
)}
</AnimatePresence>
</Dialog>
)
}
項目をずらして表示するリスト:
import { motion } from "motion/react"
export function StaggeredList({ items }) {
return (
<ul className="space-y-2">
{items.map((item, i) => (
<motion.li key={item.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05, duration: 0.25, ease: "easeOut" }}
className="rounded-lg border bg-card p-4"
>
{item.label}
</motion.li>
))}
</ul>
)
}
ぼかしを使ったずらし表示(CSSのみ、より大きなインパクト):
@keyframes stagger-in {
from {
opacity: 0;
filter: blur(5px);
transform: translateY(8px);
}
}
.stagger-item {
animation: stagger-in 800ms cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
animation-delay: calc(var(--stagger-index, 0) * 80ms);
}
{/* Assign --stagger-index per element */}
<div className="stagger-item" style={{ "--stagger-index": 0 } as React.CSSProperties}>Section 1</div>
<div className="stagger-item" style={{ "--stagger-index": 1 } as React.CSSProperties}>Section 2</div>
<div className="stagger-item" style={{ "--stagger-index": 2 } as React.CSSProperties}>Section 3</div>
控えめな終了アニメーション(Framer Motion — 表示時より動きを少なく):
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, y: 8, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -12, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.45, bounce: 0 }}
>
{children}
</motion.div>
)}
</AnimatePresence>
Tailwindのanimate-pulseを使ったスケルトンローディング:
function CardSkeleton() {
return (
<div className="rounded-lg border bg-card p-6 space-y-4">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
<div className="h-3 w-full animate-pulse rounded bg-muted" />
<div className="h-3 w-4/5 animate-pulse rounded bg-muted" />
</div>
)
}
スプリング物理とイージングの選択基準
動きの要因に基づいて、適切なタイミングモデルを選びます:
| 動きの種類 | 使用するもの | 理由 |
|---|---|---|
| ユーザー操作による動き(ドラッグ、フリック、スワイプ) | スプリング | 中断に対応し、速度を維持します |
| システムによる動き(状態変化、フィードバック) | イージング | 開始と終了が明確で、タイミングを予測できます |
| 時間の表現(進捗、読み込み) | リニア | 時間と進捗が1対1で対応します |
| 高頻度の操作(タイピング、高速な切り替え) | なし | アニメーションがノイズになり、動作が遅く感じられます |
| キーボードで開始する操作 | なし | 入力に連動し、即座に反応する必要があります |
スプリングパラメーターの指針: stiffness: 300, damping: 20(わずかに跳ねる)またはstiffness: 500, damping: 30(素早く、オーバーシュートを最小限に抑える)から始めてください。damping < 10と高いスティフネスの組み合わせは避けてください。過剰な振動が生じます。入力の勢いを保つため、ドラッグイベントからのvelocityは常に渡してください:
onDragEnd={(e, info) => {
animate(target, { x: 0 }, {
type: "spring",
velocity: info.velocity.x,
stiffness: 500,
damping: 30,
});
}}
アニメーションが遅く感じられるときは、 イージングカーブを変えずに、まず再生時間を短くしてください。ほとんどの場合、問題は再生時間にあります。
コンテナ境界のアニメーション
width や height をアニメーションさせると、レイアウトの再計算が発生します。代わりに、次のパターンを使います。
2つのdivを使うパターン: 外側のdivをアニメーションさせ(motion.div を使用)、内側のdivで計測します(ref を使用)。同じ要素を計測しながらアニメーションさせてはいけません。フィードバックループが発生します。
function AnimatedContainer({ children }) {
const [ref, bounds] = useMeasure()
return (
<motion.div
animate={{ height: bounds.height }}
transition={{ duration: 0.2, delay: 0.05 }}
style={{ overflow: "hidden" }}>
<div ref={ref}>{children}</div>
</motion.div>
)
}
ResizeObserver を使った計測フック(レイアウトの再計算が頻発する getBoundingClientRect は使いません):
function useMeasure() {
const [element, setElement] = useState(null)
const [bounds, setBounds] = useState({ width: 0, height: 0 })
const ref = useCallback((node) => setElement(node), [])
useEffect(() => {
if (!element) return
const observer = new ResizeObserver(([entry]) => {
setBounds({ width: entry.contentRect.width, height: entry.contentRect.height })
})
observer.observe(element)
return () => observer.disconnect()
}, [element])
return [ref, bounds]
}
useRefではなく コールバック ref を使い、DOM ノードの準備ができたときにオブザーバーをアタッチします。- アニメーション対象のコンテナに
overflow: hiddenを設定し、トランジション中にコンテンツをクリップします。 - 初回レンダリング時に
bounds === 0となる場合に備え、"auto"の高さにフォールバックします。 - 使用は控えめに: ボタン、アコーディオン、展開可能なセクションに使います。すべてのコンテナに使うものではありません。
AnimatePresence の高度なパターン
mode="wait"は体感時間を2倍にします。 exit が完了してから enter が始まるためです。使用する場合は、タイミングの値を半分にします。mode="sync"はレイアウトの競合を引き起こします。 enter と exit の要素が一時的に共存するためです。リストの並べ替えには、代わりにpopLayoutを使います。- exit 中の要素の操作を無効にします。
useIsPresent()を使ってdisabled={!isPresent}を設定するか、exit アニメーション中の要素にpointer-events: noneを設定します。そうしないと、ユーザーがアニメーションで消えつつある要素をクリックしてしまいます。 - ネストされた AnimatePresence では、親の exit によって子の exit も実行されるよう、
propagateプロパティが必要です。 - 対称的な印象にするには、exit を initial に合わせます。 ただし、exit のモーションは控えめにします(移動距離全体ではなく、
y: -12pxのような小さな固定値を使い、blur(4px)を加えて柔らかくします)。
View Transitions API
ページ単位やドキュメント間のトランジションには、JS アニメーションライブラリよりもネイティブの View Transitions API を優先します。
/* 1. Assign unique transition names to elements that persist across views */
.hero-image { view-transition-name: hero; }
.page-title { view-transition-name: title; }
/* 2. Style the transition pseudo-elements */
::view-transition-group(hero) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
- トランジション中は、すべての
view-transition-nameを 一意にする必要があります(同時に複数の要素が同じ名前を持つことはできません)。 - 動的に割り当てたトランジション名は、完了後に クリーンアップします。
- 未対応のブラウザーでは適切にフォールバックし、アニメーションなしでページが遷移します。
7. コンポーネント
コンポーネントの考え方
- インストールせず、コピーして所有します。 shadcn/ui のモデルでは、コンポーネントは不透明な npm パッケージではなく、プロジェクトに貼り付けてカスタマイズするソースコードです。コードは自分で所有します。
- アクセシブルなプリミティブを基盤にします。 ヘッドレス UI ライブラリ(Radix、Base UI、React Aria)で振る舞いを実装し、独自のデザイントークンでスタイルを設定します。
- 複合コンポーネントパターンを使います。 複雑なコンポーネントを、組み合わせ可能なパーツに分割します。例:
Dialog.Root、Dialog.Trigger、Dialog.Content、Dialog.Title。各パーツには個別にアクセスできます。 - クラスの制御には
cn()を使います。clsxとtailwind-mergeのユーティリティは、条件に応じて Tailwind クラスを結合するために shadcn/ui エコシステムで標準的に使われています。
// lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
- CSS 変数を通じてコンポーネント API を公開します。 コンポーネントのカスタマイズには、際限なく増える prop のバリエーションではなく、CSS カスタムプロパティを使います。
.button { background: var(--button-bg, var(--primary)); }
コンポーネントのデザイントークン(shadcn/ui モデル)
トークンの全セットについては、セクション3:実践編を参照してください。標準的なセマンティックトークンは、--background、--foreground、--card、--primary、--secondary、--muted、--accent、--destructive、--border、--input、--ring、--radius です(該当するものには、それぞれ -foreground に対応するトークンがあります)。値には OKLCH 形式を使います。
ボタンのパターン
- 水平方向のパディングは、垂直方向のパディングの2倍にします。 これにより、見慣れたボタンの形になります。
- 標準的な密度でのデフォルトの高さは32~36px です。ゆとりを持たせる場合は40pxにします。
- ラベルの長さの違いに対応するには、固定の
widthではなくmin-widthを使います。 - ラベルには、手順ではなく結果を記述します。 「変更を保存するにはクリック」ではなく、「保存」とします。
- Primary、Secondary、Ghost、Destructive の4つのバリエーションで、ほぼすべてのケースに対応できます。
フォームのパターン
- すべての入力欄を
labelに関連付けます。for/idを使用します。 - グループ化した入力欄には
fieldsetとlegendを使用します。 - 入力欄の高さはボタンの高さに合わせます。 インラインフォームでボタンと入力欄を横並びにする場合は、ぴったり揃えてください。同じ高さのトークン(デフォルトは32~36px)を使用します。
- フォーム要素はフォントを継承する必要があります。
input, button, textarea, select { font: inherit; } - エラーは離れた場所のトーストではなく、フィールドの近くにインラインで表示します。
aria-describedbyを使って、エラーメッセージを入力欄に関連付けます。 - エラーを報告するのではなく、未然に防ぎます。 制約、適切なデフォルト値、リアルタイム検証を活用します。
- プレースホルダーはラベルではありません。 プレースホルダーはフォーカス時に消えます。フローティングラベル、またはフィールドの上に常時表示されるラベルを使用します。
- なじみのあるフィールド(名前、メールアドレス、住所)には
autocomplete属性を設定します。 - Chrome の自動入力スタイルを制御します。 Chrome は
:-webkit-autofillを通じてユーザーエージェントレベルで!importantを適用し、自動入力された入力欄の背景を青にし、文字色を上書きします。Tailwind のautofill:バリアントだけでは上書きできません。次のグローバルルールを使用します。
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0px 1000px var(--background) inset !important;
-webkit-text-fill-color: var(--foreground) !important;
border-color: var(--input) !important;
transition: background-color 5000s ease-in-out 0s;
caret-color: var(--foreground);
}
inset の box-shadow が Chrome の青い背景を覆います。-webkit-text-fill-color は文字色を強制します(通常の color は上書きされます)。transition: background-color 5000s は Chrome の背景色の変更を実質的に無期限に遅らせます。4つの状態(標準、ホバー、フォーカス、アクティブ)すべてを対象にしないと、操作時に Chrome がスタイルを再適用します。注意点: 5000秒のトランジション遅延があるため、自動入力された背景色はテーマのライブ切り替え(ライト/ダークの切り替え)では更新されません。実行時にテーマを切り替えるサイトにのみ影響します。
アイコン
- 標準サイズ: 16px(インライン/高密度)、20px(デフォルト)、24px(目立たせる場合)。空の状態やヒーローセクションの装飾アイコンは、より大きく(32~48px)できます。
- 線の太さ: 1.5px~2px。テキストの視覚的な太さに合わせます。
- 一貫した
viewBox: セット内のすべてのアイコンで同じviewBox(通常は0 0 24 24)を使用します。 - テキストとの視覚的な位置合わせ:
display: inline-flex; align-items: centerを使用し、視覚的に中央揃えするために必要に応じて縦方向に -1px~-2px ずらします。 - テキストと組み合わせる場合は、アイコンのコントラストを下げます。 同じ色でも、アイコンはテキストより視覚的に強く見えます。アイコンの不透明度を下げるか、
text-muted-foregroundの色を使用します。 - 装飾アイコンには
aria-hidden="true"を使用します。アイコンのみのボタンにはaria-labelを指定します。
テーブルとデータ表示
- 数値と通貨は右揃えにします。 テキストは左揃えにします。これにより小数点が揃い、情報をすばやく確認できます。
font-variant-numeric: tabular-numsを使用して、各行で数字の幅を揃えます。- セマンティックなマークアップを使用します:
<table>、<thead>、<tbody>、<th scope="col">、<caption>。 - レスポンシブ対応のパターン: 小さな画面では、テーブルを水平方向にスクロールできるコンテナーで囲みます。
.table-wrapper { overflow-x: auto; -webkit-overflow-scrolling: touch; }
- ゼブラストライプ(行ごとに背景色を交互に変える)または水平の区切り線を使うと、幅の広いテーブルが読みやすくなります。
- 固定ヘッダー: スクロール範囲の長いテーブルでは、
thead th { position: sticky; top: 0; }を使用します。
読み込み状態
- スピナーの表示は400ms遅らせます。 操作がそれより早く完了すれば、ユーザーに読み込み UI は表示されません。これにより、読み込み表示が一瞬だけ点滅するのを防げます。
- コンテンツが多いレイアウトでは、スピナーよりスケルトン画面を使用します。 強いグラデーションのアニメーションではなく、控えめな不透明度の点滅(
animate-pulse)でアニメーションさせます。 - スケルトンは、置き換えるコンテンツのレイアウトに合わせる必要があります。 これによりレイアウトシフトを防ぎます。
- 楽観的更新: 期待される結果をすぐに表示し、エラー時には元に戻します。体感速度を高めるうえで最良の方法です。
- 2~3秒を超える操作には進行状況インジケーターを使用します。 可能な場合は確定的な進捗(パーセンテージ)を、そうでない場合は不確定な進捗を表示します。
モーダルとオーバーレイ
- 背景オーバーレイ: 半透明のオーバーレイ(
rgba(0,0,0,0.5)またはbackdrop-filter: blur(4px))。 - フォーカストラップ: Tab キーでモーダル内を循環できるようにします。開いたときに最初のインタラクティブ要素にフォーカスし、閉じたときにトリガーへフォーカスを戻します。
- body のスクロールを防止:
<body>にoverflow: hiddenを適用するか、モーダルにoverscroll-behavior: containを適用します。注意:overscroll-behavior: containはモバイルでのプルツーリフレッシュも防止するため、このトレードオフをテストしてください。 - Escape キーで閉じる: キーボードで常に閉じられるようにします。
- アプリのルートに
isolation: isolateを指定すると、ポータル表示するモーダルとの z-index の競合を防げます。
空の状態
- 空の状態は後回しにせず、デザインの機会として捉えます。
- 次に何をすべきか、明確に案内します。
- イラストや控えめなアニメーションを使い、意図的にデザインされた状態に見せます。
エラーページ
- 404 ページやエラーページは、作り込みの機会として捉えます。
- サイトの個性とナビゲーションを保ちます。
- 次に取れる行動(検索、人気ページ、ホームへのリンク)を案内します。
実践例:shadcn/ui のコンポーネントパターン
バリデーション付きフォーム(React Hook Form + Zod + shadcn):
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
const schema = z.object({
email: z.string().email("Enter a valid email"),
name: z.string().min(2, "Name must be at least 2 characters"),
})
export function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" {...register("name")} />
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" autoComplete="email" {...register("email")} />
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<Button type="submit" className="w-full">Create account</Button>
</form>
)
}
空の状態のコンポーネント:
import { Inbox } from "lucide-react"
import { Button } from "@/components/ui/button"
export function EmptyState({ title, description, action, onAction }) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
<Inbox className="h-10 w-10 text-muted-foreground/50" />
<h3 className="mt-4 font-semibold">{title}</h3>
<p className="mt-2 text-sm text-muted-foreground max-w-sm">{description}</p>
{action && (
<Button onClick={onAction} className="mt-4" size="sm">{action}</Button>
)}
</div>
)
}
shadcn を使ったデータテーブル(主なパターン):
import {
Table, TableBody, TableCell, TableHead,
TableHeader, TableRow,
} from "@/components/ui/table"
export function InvoiceTable({ invoices }) {
return (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map(inv => (
<TableRow key={inv.id}>
<TableCell className="font-medium">{inv.id}</TableCell>
<TableCell>
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2 py-0.5
text-xs font-medium text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
{inv.status}
</span>
</TableCell>
<TableCell className="text-right tabular-nums">{inv.amount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
遅延スピナーによる読み込み表示(ちらつきを防止):
"use client"
import { useEffect, useState } from "react"
import { Loader2 } from "lucide-react"
export function DelayedSpinner({ delay = 400 }) {
const [show, setShow] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setShow(true), delay)
return () => clearTimeout(timer)
}, [delay])
if (!show) return null
return <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
}
8. レスポンシブデザイン
ブレークポイント戦略
デバイス名ではなく、コンテンツのニーズに基づいてブレークポイントを定義します。必要最小限のブレークポイントを使用します。
| トークン | 幅 | 典型的な用途 |
|---|---|---|
| sm | 640px (40rem) | 横向きのスマートフォン |
| md | 768px (48rem) | 縦向きのタブレット |
| lg | 1024px (64rem) | 横向きのタブレット/小型ノートパソコン |
| xl | 1280px (80rem) | デスクトップ |
| 2xl | 1536px (96rem) | 大型デスクトップ |
レスポンシブのルール
- モバイルファーストでデザインします。 最小のレイアウトから始め、広げていきます。
- ブレークポイントの値には
remを使用します。 ユーザーのフォントサイズ設定に応じてレイアウトが変わるようにします。 - コンポーネント単位のレスポンシブ対応にはコンテナクエリを使用します。 コンポーネントはビューポートではなく、コンテナに合わせて調整します:
@container (min-inline-size: 400px) {
.card { flex-direction: row; }
}
- ブレークポイントより固有サイズ指定を優先します。
auto-fill/auto-fitグリッド、flex-wrap、clamp()を使い、メディアクエリなしで調整できるレイアウトを作ります。 - 画面が小さいという理由だけでコンテンツを非表示にしないでください。 画面が小さくても、ユーザーに必要な情報が少なくなるわけではありません。
- 行の長さを最適化します。 1 行の文字数が 75 文字を超える場合は、ブレークポイントを追加します。
- 200% の拡大率でもテキストが読みやすいか確認します(WCAG の要件)。
- 操作方法を確認します。
@media (hover: hover)と@media (pointer: coarse)を使って、タッチ操作とマウス操作に対応します:
@media (pointer: coarse) {
.button { min-height: 48px; }
}
実践例:Tailwind のレスポンシブパターン
レスポンシブなカードレイアウト(モバイルでは縦積み → デスクトップではグリッド):
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map(item => <Card key={item.id} {...item} />)}
</div>
画面サイズに応じて変化するナビゲーション(デスクトップではサイドバー、モバイルではシート):
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
import { Menu } from "lucide-react"
export function AppShell({ nav, children }) {
return (
<div className="flex min-h-screen">
{/* Desktop sidebar */}
<aside className="hidden lg:flex w-64 flex-col border-r p-4">{nav}</aside>
{/* Mobile sheet */}
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden fixed top-4 left-4 z-40">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64 p-4">{nav}</SheetContent>
</Sheet>
<main className="flex-1 p-6 lg:p-8">{children}</main>
</div>
)
}
Tailwind のコンテナクエリ(親要素の幅に合わせて調整するコンポーネント):
<div className="@container">
<div className="flex flex-col @md:flex-row @md:items-center gap-4">
<img className="w-full @md:w-48 rounded-lg" src={src} alt={alt} />
<div className="space-y-2">
<h3 className="font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
</div>
9. アクセシビリティ
コントラスト比(WCAG 2.1)
| 要素 | AA の最小値 | AAA の最小値 |
|---|---|---|
| 通常のテキスト | 4.5:1 | 7:1 |
| 大きなテキスト(太字 18px 以上、または 24px 以上) | 3:1 | 4.5:1 |
| UI コンポーネントとアイコン | 3:1 | -- |
| 入力欄の境界線 | 3:1 | -- |
キーボード操作
- すべてのインタラクティブ要素をキーボードで操作できるようにします。
- すべてのインタラクティブ要素に視認できるフォーカススタイルを設定します。キーボードユーザーにのみアウトラインを表示するには、
:focusではなく:focus-visibleを使用します:
:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
- Tab の移動順序は視覚的な順序と一致させます。 キーボード操作を妨げる形でコンテンツを並べ替えるために、CSS Grid/Flexbox の
orderを使用しないでください。 - ナビゲーションをスキップできるスキップリンクを実装します。
- フォーカス可能でも見えない要素を Tab の移動順序から除外します。
Escapeでオーバーレイを閉じ、Enter/Spaceでボタンを操作できるようにします。
セマンティック HTML
- ランドマーク要素を使用します:
<header>、<nav>、<main>、<footer>、<aside>。 - 適切な見出し階層を使用します。各ページの
<h1>は1つだけとし、レベルを飛ばさないでください。 - 操作には
<button>を、ナビゲーションには<a>を使用します。 - リストには
<ul>/<ol>を、表形式のデータには<table>を使用します。 <html>にlang属性を含めます。- ページごとに固有で内容がわかりやすい
<title>を設定します。
画像とメディア
- すべての
<img>にalt属性が必要です。装飾画像にはalt=""を指定します。 - レイアウトシフトを防ぐため、画像に
widthとheightを指定します。 - 音声と動画の自動再生を無効にします。
- 動画にはキャプションを、音声には文字起こしを用意します。
- メディアコントロールをキーボードで操作できるようにします。
フォーム
- エラー/警告/成功の状態を色だけで表さないでください。テキスト、アイコン、パターンなどを併用します。
aria-describedbyを使用して、エラーメッセージを入力欄に関連付けます。- スクリーンリーダーの利用者向けに、フォームの上部にエラーをリスト表示します。
- エラーメッセージは、問題の内容と解決方法を提示する、対応可能なものにします。
- 無効化された送信ボタンには、無効である理由を説明する必要があります。 不足している項目を「必須項目をすべて入力してください」のように示すか、ボタンを有効にしたまま送信時に検証します。
- 重要な情報を伝える手段として、トーストだけに頼らないでください。 トーストは消えてしまいます。エラー、破壊的な操作の確認、重要な状態変更は、インライン UI で表示する必要があります。
- 読み込み状態では、読み込み領域に
aria-busy="true"を指定し、状態を示すテキストも表示します。 - 補助テキストは、見た目上近くに配置するだけでなく、
aria-describedbyで入力欄に関連付けます。
モーション
prefers-reduced-motionを尊重します。不可欠でないアニメーションは、減らすか削除します。- 1秒間に3回を超えて点滅するコンテンツは避けます(発作のリスクがあります)。
- 自動再生されるコンテンツをユーザーが一時停止できるようにします。
タッチターゲットとヒット領域
- タッチターゲットの最小サイズは44×44px(WCAG)、48×48pxが推奨です(Material Design)。
- 誤タップを防ぐため、インタラクティブ要素同士を少なくとも8px離します。
- 絶対位置指定の疑似要素を使って、見た目のサイズよりヒット領域を広げます。これによってレイアウトには影響しません。
/* Small checkbox (20×20) with expanded 44×44 hit area */
.checkbox {
position: relative;
width: 20px;
height: 20px;
}
.checkbox::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
}
Tailwind での同等の方法:
<button class="relative size-5 after:absolute after:top-1/2 after:left-1/2 after:size-11 after:-translate-1/2">
<CheckIcon />
</button>
- 重なりのルール: 拡張したヒット領域が別のインタラクティブ要素と重なる場合は、ヒット領域を縮小します。ただし、重ならない範囲でできるだけ大きく保ちます。2つのインタラクティブ要素のヒット領域が重なってはいけません。
- 操作できない領域をなくします。 よくある問題として、テーブルのセル内のチェックボックスに余白があるのに、小さなチェックボックス部分しかクリックできないことがあります。セル全体にヒット領域を広げ、余白内のどこをクリックしても操作できるようにします。
- ナビゲーションの視覚的な隙間を埋めます。
gap-y-pxを使ったサイドバーナビゲーションの項目は、見た目には分かれていても、途切れずに操作できるようにします。ヒット領域を隙間まで広げ、項目間に操作できない箇所がないようにします。 - Tailwind にネイティブ対応した方法として、
hit-areaユーティリティ(bazza.dev/r/hit-area)を使用します。npx shadcn@latest add https://bazza.dev/r/hit-areaでインストールできます。一様な拡張(hit-area-4)、方向指定(hit-area-l-8 hit-area-r-4)、軸指定(hit-area-x-4 hit-area-y-6)、カスタム指定(hit-area-[21px])に対応しています。開発中の可視化にはhit-area-debugを使用します。
10. パフォーマンス
Core Web Vitals のしきい値
| 指標 | 良好 | 説明 |
|---|---|---|
| LCP | < 2.5s | Largest Contentful Paint(読み込み) |
| INP | < 200ms | Interaction to Next Paint(応答性) |
| CLS | < 0.1 | Cumulative Layout Shift(視覚的安定性) |
パフォーマンスのルール
- すべての画像に
widthとheightを設定し、レイアウトシフトを防ぎます。 - ビューポートより下にある画像には
loading="lazy"を使用します。 - すべてのメディア要素に
max-width: 100%とdisplay: blockを適用します。 - フォントのサブセット化により、必要な文字だけを含めます。ペイロードを削減します。
font-display: swapを使用し、フォントの読み込み中にテキストが表示されなくなるのを防ぎます。- JavaScript より CSS アニメーションを優先します。 CSS トランジションはメインスレッドの外で実行されます。
- 60fps のパフォーマンスを保つため、アニメーションには
transform、opacity、clip-pathのみを使用します。 - GPU アクセラレーションを促すヒントとして、
will-change: transformを控えめに使用します。 - 画面外のコンテンツには
content-visibility: autoを使用し、レンダリングをスキップします。 - スクロールバーの表示によるレイアウトシフトを防ぐため、
scrollbar-gutter: stableを使用します。 transition: allは避けます。アニメーションさせるプロパティを明示してください。- DOM の読み取りと書き込みを交互に行わないでください。 同じフレーム内で
getBoundingClientRect()を読み取ってからstyle.leftに書き込むと、レイアウトスラッシングが発生します。読み取りをすべてまとめてから、書き込みを行ってください。 - レイアウトに近いアニメーションには FLIP テクニック(First、Last、Invert、Play)を使用します。開始位置を測定し、最終状態を適用して差分を計算し、逆方向の変換をアニメーションさせます。
- ぼかしアニメーションは小さく(<=8px)、短くします(単発のエフェクトのみ)。ぼかしを連続的にアニメーションさせたり、広い範囲に適用したりしないでください。
will-changeはアニメーション中のみ適用します。mouseenterで追加し、transitionendで削除してください。常に適用したままにしないでください。- Tailwind では
h-screenの代わりにh-dvhを使用します。dvhはモバイルブラウザーの UI(アドレスバー、ツールバー)を考慮します。h-screenを使用すると、モバイルでコンテンツがブラウザー UI の背後に隠れてしまいます。
画像の最適化
- WebP、AVIF などのモダンな形式を、フォールバックと併せて配信します。
- レスポンシブ画像には
srcsetとsizes属性を使用します。 aspect-ratio: 16/9のように、明示的な寸法を設定してスペースを確保します。- 想定外のアスペクト比に対応するため、
object-fit: coverを使用します。
実践例:Next.js のパフォーマンス
Next.js の Image コンポーネント(srcset、WebP/AVIF、遅延読み込みに対応):
import Image from "next/image"
// Replaces <img> — automatically optimized, lazy-loaded, prevents CLS
<Image
src="/hero.jpg"
alt="Product screenshot"
width={1200}
height={630}
className="rounded-lg"
priority // Add for above-fold hero images only
/>
// For fill mode (unknown dimensions):
<div className="relative aspect-video">
<Image src={url} alt={alt} fill className="object-cover rounded-lg" />
</div>
Next.js でのフォント最適化(サブセット化と swap を自動化):
// next/font handles subsetting, font-display: swap, and self-hosting automatically
import { Inter } from "next/font/google"
const inter = Inter({ subsets: ["latin"] })
// Generates: font-display: swap, optimal subsetting, no layout shift
重いコンポーネントの動的インポート:
import dynamic from "next/dynamic"
// Chart library only loads when needed, with skeleton fallback
const Chart = dynamic(() => import("@/components/chart"), {
loading: () => <div className="h-64 animate-pulse rounded-lg bg-muted" />,
ssr: false,
})
楽観的更新パターン(React):
"use client"
import { useOptimistic } from "react"
function TodoList({ todos, addTodo }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state, newTodo) => [...state, { ...newTodo, pending: true }]
)
async function handleAdd(formData) {
const todo = { text: formData.get("text"), id: crypto.randomUUID() }
addOptimistic(todo) // Show immediately
await addTodo(todo) // Server action — rolls back on error
}
return (
<ul className="space-y-1">
{optimisticTodos.map(todo => (
<li key={todo.id} className={todo.pending ? "opacity-50" : ""}>
{todo.text}
</li>
))}
</ul>
)
}
11. UX 心理学
主要な法則
フィッツの法則。 ターゲットに到達するまでの時間は、距離とサイズによって決まります。重要なアクションは大きくし、カーソルの近くに配置します。よく使うターゲットまでの距離を短くします。デスクトップインターフェースで画面の端や隅に配置すると、一方の軸に沿って「無限」の大きさを得られます。
ヒックの法則。 選択肢の数と複雑さが増えるほど、意思決定にかかる時間も長くなります。各ステップでの選択肢を最小限にします。複雑なタスクは、小さな意思決定に分割します。段階的開示を活用します。デフォルトを推奨します。
ミラーの法則。 ワーキングメモリに保持できる項目数は、およそ7(±2)です。情報は理解しやすいグループにまとめます。これを、UIの項目数を7つに制限する言い訳にしてはいけません。グループ化の方法を決める際の指針として活用します。
ヤコブの法則。 ユーザーは時間の大半を、ほかのサイトで過ごしています。そのため、自分のサイトも同じように動作すると期待します。プラットフォームの慣習に従います。十分な理由がない限り、インタラクションパターンに独自の工夫を加えないようにします。
ドハーティのしきい値。 システムの応答時間が400ms未満になると、生産性は大きく向上します。スケルトンスクリーン、楽観的な更新、読み込みインジケーターを活用し、体感速度を維持します。
美的ユーザビリティ効果。 ユーザーは、魅力的なデザインほど使いやすいと感じます。見た目の仕上げに力を入れます。これは、知覚される品質と、問題に対するユーザーの寛容さに直接影響します。
フォン・レストルフ効果(孤立効果)。 グループの中で目立つ項目ほど、記憶に残りやすくなります。最も重要な要素(CTA、主要な指標)には、視覚的に際立つデザインを用います。
ピーク・エンドの法則。 人は、体験全体の平均ではなく、最も印象的な瞬間と最後の印象で体験を評価します。クライマックスとなる場面と最後の印象に、重点的に投資します。
ツァイガルニク効果。 人は、完了したタスクより未完了のタスクをよく覚えています。プログレスバーやステップインジケーターを使うと、完了まで取り組むよう促せます。
テスラーの法則(複雑さ保存の法則)。 どのシステムにも、取り除けない複雑さがあります。問題は、それをユーザーとシステムのどちらが負担するかです。実装側で複雑さを吸収し、インターフェースをシンプルに保ちます。
ポステルの法則。 受け入れるものには寛容に、生成するものには厳格に対応します。さまざまな入力形式を受け入れ、整然と一貫した形式で出力します。
追加の法則
選択肢過多。 選択肢が多すぎると、ユーザーは圧倒され、満足度が低下します。フィルタリングを可能にし、デフォルトを推奨し、複雑な意思決定を段階に分けます。
目標勾配効果。 人は目標に近づくほど、行動のペースを速めます。プログレスバーを表示し、途中までの進捗をあらかじめ反映し、複数のステップからなるプロセスを常に見えるようにします。
系列位置効果。 ユーザーは、リストの最初と最後の項目を最もよく覚えます。重要なナビゲーション項目は最初と最後に配置します。重要度の低い項目は中間に配置します。
パレートの原則(80/20)。 利用の80%は、機能の20%によって生じます。重要な20%を特定し、最適化します。
選択的注意(バナーブラインドネス)。 ユーザーは、広告のように見えるものを無視します。実際のコンテンツを広告のような見た目にしないでください。
ゲシュタルトの法則
- 近接。 互いに近くにあるオブジェクトは、ひとまとまりとして認識されます。
- 類同。 見た目が似ているオブジェクトは、関連していると認識されます。
- 連続。 線や曲線に沿って配置された要素は、関連していると認識されます。
- 閉合。 人の心は、不完全な形を補って認識します。
- 共通領域。 同じ境界の中にある要素は、ひとまとまりとして認識されます。
- 図と地。 人の心は、前景と背景を区別します。
12. デザイントークン
トークンのアーキテクチャ
トークンは階層化して整理します。
- プリミティブトークン。 生の値です。例:
--blue-500: #3b82f6。これらがパレットになります。 - セマンティックトークン。 用途に対応付けられた値です。例:
--color-primary: var(--blue-500)。これらが意味を表します。 - コンポーネントトークン。 スコープを限定した値です。例:
--button-bg: var(--color-primary)。コンポーネント単位での上書きが可能になります。
標準トークンのカテゴリ
色: background、foreground、primary、secondary、muted、accent、destructive、border、input、ring、およびセマンティックカラー(success、warning、info、error)。
タイポグラフィ: font-family(sans、heading、mono)、フォントサイズ(xs から 4xl まで)、フォントウェイト(normal、medium、semibold、bold)、行の高さ、文字間隔。
余白: 4px グリッドに基づきます(セクション 1 を参照)。
ボーダー: 角丸(sm、md、lg、xl、full)、幅(1px、2px)、色(カラー トークンから指定)。
シャドウ: sm、md、lg、xl、2xl(セクション 4 を参照)。
モーション: 期間(fast:150ms、normal:250ms、slow:500ms)、イージング関数。
Z-index: レイヤーのスケール:base(0)、dropdown(1000)、sticky(1100)、modal(1200)、popover(1300)、toast(1400)。
CSS カスタムプロパティのパターン
:root {
/* Primitives */
--gray-50: #fafafa;
--gray-900: #171717;
--blue-500: #3b82f6;
/* Semantic */
--background: var(--gray-50);
--foreground: var(--gray-900);
--primary: var(--blue-500);
/* Component */
--button-bg: var(--primary);
--button-text: white;
--card-bg: var(--background);
--card-border: var(--gray-200);
}
.dark {
--background: var(--gray-900);
--foreground: var(--gray-50);
--primary: var(--blue-400);
}
レスポンシブトークン
コンポーネントに触れずに、ブレークポイントでトークンの値を変更できます。
:root {
--space-section: 2rem;
--text-hero: 2rem;
}
@media (min-width: 768px) {
:root {
--space-section: 4rem;
--text-hero: 3.5rem;
}
}
13. 仕上げとこだわり
良いデザインと優れたデザインの違い
アマチュアとプロフェッショナルのデザインの違いは、意図を持った小さな判断を何百と積み重ねた結果です。製品を変えるような単一の細部はありませんが、それらが積み重なることで、ユーザーが「なぜかしっくりくる」と表現する品質が生まれます。
大切にしたいデザイン原則
- 統一感。 すべての要素が一貫したシステムに貢献します。孤立した機能はありません。
- 普遍性。 製品は、世界中のコミュニティにとって親しみやすく、利用しやすいものであるべきです。
- 象徴性。 デザインと機能の両方に集中します。大胆かつ明確に伝えます。
- 対話性。 モーションは製品に命を吹き込みます。わかりやすい方法で伝えます。
- 細部まで徹底する。 エラーページ、空の状態、フッターなど、すべての画面に気を配ります。
- デザインはできるだけ少なく。 良いデザインとは、できるだけ少ないデザインです。より少なく、しかしより良く。
- 難しい作業を見えなくする。 ユーザーにとって物事をシンプルにするために、難しい作業に取り組みます。
- 考えさせない。 インターフェースはひと目でわかるべきです。説明が必要なら、再設計が必要です。
アマチュアとプロフェッショナルのパターン
| アマチュア | プロフェッショナル |
|---|---|
| 根拠のないアニメーション時間 | 意図的な時間設定:100~300ms の範囲で、トリガーの大きさに応じて調整 |
| 状態の変化中にレイアウトがずれる | 非表示の疑似要素で太字テキストの幅を確保 |
| 画像に硬い境界線を付ける | 半透明色を使った inset box-shadow |
| クリック領域が見た目のサイズと完全に一致 | 見えない ::before でクリック可能領域を拡大 |
| エラーページ、空の状態、フッターを軽視する | すべての画面を仕上げの機会と捉える |
| タッチデバイスでもホバー効果が発生する | @media (hover: hover) でホバー状態を切り分ける |
| テーマの切り替え中に CSS トランジションが発生する | トランジションを無効にしてテーマを適用し、1 フレーム後に再度有効にする |
| すべての操作に同じアニメーションを使う | 頻繁な操作ではモーションを最小限にし、意味のある状態変化にだけ使う |
ドロップダウンを click で開く | すぐに反応しているように感じられるよう、mousedown で起動する |
| サーバーによる UI 更新を待つ | 楽観的更新を行い、エラー時にはロールバックする |
仕上げの工夫
- アクセントボーダー。 カードの片側に 3~5px の色付きボーダーを付けると、重たい印象を与えずに視覚的なアクセントを加えられます。
- 背景の装飾。 控えめなグラデーション、ドット、グリッドパターンを使うと、平坦で空虚な領域を防げます。
- 選択範囲の色のカスタマイズ。 ブランドの一貫性を保つために
::selectionをスタイルします(十分なコントラストを確保してください)。 - ダークモード対応の SVG ファビコン。 SVG 内で
prefers-color-schemeメディアクエリを使い、状況に応じてファビコンを切り替えます。 - 必要に応じてスクロールバーをカスタマイズします(控えめに使用し、使いやすさを確保してください)。
実践例:React + Tailwind の仕上げパターン
アクセントボーダー付きカード(左側に色付きバー):
<div className="rounded-lg border-l-4 border-l-blue-500 bg-card p-4 shadow-sm">
<h3 className="font-semibold">New deployment</h3>
<p className="text-sm text-muted-foreground">Production build completed in 42s.</p>
</div>
自然なインセットボーダーの画像(硬い境界線なし):
<div className="relative overflow-hidden rounded-lg">
<img src={src} alt={alt} className="w-full" />
<div className="absolute inset-0 rounded-lg ring-1 ring-inset ring-black/5" />
</div>
タッチ操作を除外したホバー専用の効果(Tailwind):
<button className="rounded-lg bg-secondary px-4 py-2
transition-colors
hover:[@media(hover:hover)]:bg-secondary/80
active:scale-[0.97]">
Action
</button>
globals.css で選択範囲の色をカスタマイズ:
::selection { background-color: oklch(0.8 0.1 250); color: oklch(0.2 0 0); }
キーボードユーザー向けのスキップリンク:
<a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4
focus:z-50 focus:rounded-lg focus:bg-background focus:px-4 focus:py-2 focus:text-sm
focus:shadow-lg focus:ring-2 focus:ring-ring">
Skip to content
</a>
<main id="main">{children}</main>
ダークモード対応のレスポンシブ SVG ファビコン(app/icon.svg):
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<style>
circle { fill: #111 }
@media (prefers-color-scheme: dark) { circle { fill: #eee } }
</style>
<circle cx="16" cy="16" r="14" />
</svg>
14. マイクロコピーと UX ライティング
基本原則
- 簡潔さを優先します。 ボタンのラベルは1〜3語にします。「変更を保存するにはクリックしてください」ではなく「保存」、「このテキストをクリップボードにコピー」ではなく「コピー」とします。
- ラベルは手順ではなく結果を表します。 「登録を送信」ではなく「参加」、「この項目をデータベースから完全に削除」ではなく「削除」とします。
- 行動を促す言葉を使います。 CTAには「ウェイトリストに参加」「始める」「続ける」のように動詞を使います。名詞句は使いません。
- アクションの重さに合わせて文言の重みを調整します。 破壊的なアクションには、より慎重な文言が必要です。削除の確認文は、切り替えラベルより重みのある表現にします。
- 段階的開示は文言にも適用します。 情報は一度にすべて提示せず、理解しやすい段階に分けて示します。
ボタンとCTAのラベル
| パターン | 例 |
|---|---|
| 1語の動詞 | 保存、コピー、削除、参加、共有 |
| 短いフレーズ | ウェイトリストに参加、始める、詳しく見る |
| 状態が変わるラベル | コピー -> コピーしました、保存 -> 保存しました、送信 -> 送信しました |
| 方向を示すナビゲーション | 前へ / 次へ、戻る / 続ける |
読み込みと進行状況の状態
- 3段階で進行します。 「思考中...」 -> 「生成中...」 -> 「完了」
- ユーザーがすべきことではなく、システムが行っていることを説明します。
- 処理中の状態には省略記号を使い(保存中...、読み込み中...)、完了時には使いません(完了、保存済み)。
- 段階的に具体化します。 抽象的な表現(「処理中...」)から始め、具体的な表現(「完了」)で終えます。
エラーメッセージ
- エラーを報告するより、発生を防ぎます。 送信後にエラーの一覧を表示するより、リアルタイムの入力検証が効果的です。
- 問題を伝え、解決方法を提案します。 「無効なパスワード」ではなく「パスワードには数字を含めてください」とします。
- テキストと視覚的なフィードバックを組み合わせます。 振動アニメーション、赤い枠線、インラインメッセージを併用します。
- 平易な言葉を使います。 「エラー 409:競合」ではなく「このメールアドレスはすでに登録されています」とします。
成功と確認
- 簡潔にし、自動的に消えるようにします。 トーストには「コピーしました」と表示し、1.5〜2秒後に自動で消します。
- 完了時には温かみのある表現を使います。 「すべて完了しました」は「操作が正常に完了しました」より適切です。
- 次のステップを提案します。 確認後に、関連する次のアクションを提示します。
トーンのガイドライン
- 会話調でありながら正確にします。 親しみやすく、技術的にも正確な表現にします。
- 曖昧さを避け、自信を持って伝えます。 「アカウントが削除される可能性があります」ではなく「アカウントが削除されます」とします。
- 入力段階では手順を示し、完了時には達成感を伝えます。
- 決して見下した表現を使いません。 日常的な操作で感嘆符を使うのは避け、実際に大きな節目を迎えたときに限ります。
15. AIスロップの防止
AIコード生成ツールが使いがちな、汎用的で質が低く、いかにも機械が作ったように見えるパターンです。これらは避けてください。
視覚的なアンチパターン
- ブランドで明確に採用されていない限り、紫色やマルチカラーのグラデーションは使いません。 これはAIが生成したデザインだと最も見分けやすい特徴です。
- 主な視覚的手がかりとして、グロー効果を使いません。 グローは装飾であり、機能ではありません。ボタンはグローがなくても認識できるようにします。
- 汎用的なフォントスタックは使いません。 何にでもInter、Roboto、system-uiを使うのは避けます。プロジェクトの個性に合う書体を選びます。ブランドの方向性が定まっていない場合はInterを使ってもかまいませんが、デザイン上の選択ではなく、出発点として位置づけます。
- 1つのビューにつきアクセントカラーは1色にします。 競合するアクセントカラーを複数使うと、視覚的なノイズが生じます。主要なアクションに使う色を1色選び、それ以外はニュートラルにします。
- 依頼がない限り、グラデーションは使いません。 基本は単色にします。グラデーションは意図的に選ぶものであり、代替手段ではありません。
- コンテンツに役立つ場合を除き、装飾的なSVGのブロブ、メッシュグラデーション、抽象的な図形は使いません。 これらは埋め草であり、「デザインの方向性がない」ことを示します。
構造上のアンチパターン
- 同じ指標カードを並べないでください。「左にアイコン、大きな数値、小さなラベル」というパターンをグリッド内で4回繰り返すと、テンプレート感が強くなります。コンテンツの種類に応じて、カード内部の構成に変化をつけてください。
- お決まりのサイドバー+カードグリッドは避けてください。 AI はこのレイアウトをデフォルトにするため、どのダッシュボードも同じ見た目になってしまいます。ユーザーの主要なタスクを起点に、レイアウトを組み立ててください。
- すべてを中央揃えにしないでください。 テキストもカードもヒーローも中央揃えにするのは、AI のデフォルトです。基本は左揃えにしてください。中央揃えにするのは、コンテンツが短く、意図的に対称にする場合(ヒーローの見出し、空の状態など)だけです。
- 画像とテキストを左右交互に配置したセクションを縦に積み重ねただけのランディングページは避けてください。 リズムに変化をつけてください。
コード上のアンチパターン
h-screenは使わないでください。h-dvh(動的ビューポートの高さ)を使ってください。h-screenでは、モバイルブラウザーの UI の背後にコンテンツが隠れてしまいます。z-[9999]は使わないでください。 トークンのスケールを使ってください:dropdown(1000)、sticky(1100)、modal(1200)、popover(1300)、toast(1400)。transition-allは使わないでください。 トランジションを適用するプロパティを明示してください。- デザイン上の判断にインラインスタイルを使わないでください。 Tailwind のユーティリティまたは CSS 変数を使ってください。レイアウト値(計算された幅、JS で指定する位置)にはインラインスタイルを使ってもかまいません。
- レンダリングロジックに
useEffectを使わないでください。 派生状態はエフェクト内ではなく、レンダリング中に計算してください。
「人間がこれをリリースするだろうか?」テスト
UI の作業を完了とする前に、次の点を確認してください。
- 特徴的な要素: このデザインがこの製品ならではであることを示す要素を1つ挙げられますか? できなければ、テンプレートです。
- 目を細めて見るテスト: ぼかして見たとき、階層が明確ですか? 主要なアクションがひと目でわかりますか?
- 入れ替えテスト: ロゴを入れ替えても、ほかのどの製品にも見えてしまいませんか? そうなら、デザインに独自性がありません。
- 新鮮な目で確認: 翌日見ても、まだしっくりきますか? それとも、何か気になる点がありますか?
16. 高度な仕上げ
洗練された本番用インターフェースとプロトタイプを分けるテクニックです。
自然な影
Tailwind のデフォルトの影にはグレーが使われます。より豊かな奥行きを出すには、背景色の色相に合わせて影に色を付けます。
:root {
--shadow-color: 220deg 60% 50%; /* match your background hue */
}
.card-natural-shadow {
box-shadow:
0 1px 1px hsl(var(--shadow-color) / 0.075),
0 2px 2px hsl(var(--shadow-color) / 0.075),
0 4px 4px hsl(var(--shadow-color) / 0.075),
0 8px 8px hsl(var(--shadow-color) / 0.075);
}
セクションやテーマごとに --shadow-color を変更し、影が周囲の色になじむようにします。ダークモードでは、暗い背景上では影の視覚的な意味が薄れるため、代わりに控えめな border と明るめのサーフェス(bg-card)を使ってください。
カラーパレットの生成
単一のベースカラーから色の状態を作成するには(HSB/HSL):
- 暗いバリエーション(ホバー、アクティブ): 明度を下げ、彩度を上げます。必要に応じて、色相をブルー/バイオレット寄りに移動させます。
- 明るいバリエーション(色付きの背景、バッジ): 明度を上げ、彩度を下げます。必要に応じて、色相をイエロー/シアン寄りに移動させます。
- 無効状態: 彩度を大幅に下げ、明度を上げます。
{/* Primary button with derived hover/active states */}
<button className="bg-blue-600 hover:bg-blue-700 active:bg-blue-800
text-white transition-colors">
Confirm
</button>
{/* Tinted background from same color family */}
<div className="bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300 rounded-lg p-4">
Info message using the same blue family at lower intensity.
</div>
濁らないグラデーション
標準的な CSS のグラデーションは sRGB で補間されるため、彩度の高い色の中間色が濁って見えます。OKLCH 補間を指定して解決します。
/* Muddy (default sRGB interpolation) */
background: linear-gradient(to right, #ff0 , #00f);
/* Clean (perceptually uniform) */
background: linear-gradient(to right in oklch, oklch(0.9 0.4 110), oklch(0.5 0.3 264));
Tailwind では、任意値を使います。
<div className="bg-[linear-gradient(to_right_in_oklch,oklch(0.8_0.15_200),oklch(0.7_0.2_280))]" />
角の滑らかさ(スクワークル)
CSS の border-radius は円弧を作り、直線の辺と接する点がはっきりした接線になります。iOS ではスーパー楕円(スクワークル)が使われており、円弧になる前から曲がり始めるため、より滑らかで自然な形になります。
CSS のみで対応する方法(ほとんどの場合これで十分です): 半径を大きくします。小さい方の寸法の約22~28%にあたる border-radius にすると、スクワークルのような印象になります。一般的なカードサイズでは、カードに Tailwind の rounded-2xl(16px)から rounded-3xl(24px)を指定すると、近い見た目になります。
iOS にピクセル単位で忠実に再現する場合は、SVG のスーパー楕円を使った clip-path、または figma-squircle パッケージを使います。
import { getSvgPath } from "figma-squircle"
function Squircle({ width, height, children }) {
const path = getSvgPath({
width, height,
cornerRadius: 20,
cornerSmoothing: 0.6, // 0 = CSS radius, 1 = full squircle
})
return (
<div style={{ clipPath: `path('${path}')`, width, height }}>
{children}
</div>
)
}
重要となる場面: アプリアイコン、ヒーローカード、高級感のある製品画像です。一般的な UI カードなら、rounded-xl または rounded-2xl で十分です。
グラスモーフィズム/すりガラス
<div className="rounded-xl border border-white/20 bg-white/10 p-6
backdrop-blur-xl shadow-xl">
<h3 className="font-semibold text-white">Glass Card</h3>
</div>
ルール:
- 控えめな効果には
backdrop-blur-md(12px)、強いすりガラス効果にはbackdrop-blur-xl(24px)を使用します。 - 背景は半透明にします(
bg-white/10からbg-white/20)。 - ガラスの縁に光を反射させるため、
border border-white/20を追加します。 - ぼかし効果が見える、情報量の多い背景や色鮮やかな背景で最も効果を発揮します。
- モバイルでパフォーマンスをテストしてください。
backdrop-filterは GPU に大きな負荷をかけます。 - フォールバックも適切に機能します。
backdrop-filterがサポートされていない場合、半透明のカードが表示されます(そのまま使用できます)。
ノイズとテクスチャ
単色の表面は無機質に感じられることがあります。控えめなノイズオーバーレイを加えると、自然な温かみが生まれます。
.textured {
position: relative;
}
.textured::after {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
opacity: 0.03;
pointer-events: none;
mix-blend-mode: overlay;
border-radius: inherit;
}
不透明度は 0.02~0.05 に保ちます。これより高いと、表面が汚れて見えます。
グラデーションマスク(スクロール時のフェード)
スクロールコンテナの境界でコンテンツをフェードアウトします。
<div className="relative max-h-64 overflow-y-auto
[mask-image:linear-gradient(to_bottom,transparent,black_16px,black_calc(100%-16px),transparent)]">
{/* scrollable content */}
</div>
インセットリングとボーダー
画像コンテナやアバターフレームには、ボーダーよりインセットリングのほうが自然に見えます。要素の寸法が増えず、コンテンツになじむためです。
{/* Hard border (adds 2px to dimensions, feels rigid) */}
<img className="rounded-full border-2 border-gray-200" />
{/* Inset ring (no dimension change, blends naturally) */}
<div className="relative rounded-full overflow-hidden">
<img className="rounded-full" src={src} alt={alt} />
<div className="absolute inset-0 rounded-full ring-1 ring-inset ring-black/10" />
</div>
{/* Shorthand for non-image elements */}
<div className="rounded-lg shadow-[inset_0_0_0_1px_rgba(0,0,0,0.06)]">
型付き CSS カスタムプロパティ
@property を使うと、カスタムプロパティのアニメーションが可能になり、無効な値も検出できます。
@property --gradient-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.rotating-border {
--gradient-angle: 0deg;
border-image: conic-gradient(from var(--gradient-angle), #e92a67, #a853ba, #2a8af6, #e92a67) 1;
animation: rotate-gradient 3s linear infinite;
}
@keyframes rotate-gradient {
to { --gradient-angle: 360deg; }
}
@property がないと、CSS はカスタムプロパティを補間できません。そのため、アニメーションは滑らかに変化せず、値が切り替わります。
ボタンのシャドウの構造(6 層)
洗練されたボタンでは、現実の光の物理的な動きを再現するために、6 つのレイヤー技法を組み合わせます。
.button-polished {
/* Subtle gradient for dimension */
background: linear-gradient(
to bottom,
color-mix(in oklch, var(--primary), white 4%),
var(--primary)
);
box-shadow:
/* 1. Outer cut: thin dark edge */
0 0 0 0.5px rgba(0, 0, 0, 0.15),
/* 2. Inner ambient highlight: light from all sides */
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
/* 3. Inner top highlight: primary light from above */
inset 0 1px 0 rgba(255, 255, 255, 0.15),
/* 4-6. Layered depth shadows */
0 1px 2px rgba(0, 0, 0, 0.15),
0 2px 4px rgba(0, 0, 0, 0.1),
0 4px 8px rgba(0, 0, 0, 0.05);
/* Text shadow for contrast */
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
}
この技法により、平面的なボタンに、物理的に説得力のある奥行きを与えられます。グラデーションが立体感を加え、インセットシャドウが環境光と方向性のある光を再現し、外側のシャドウが奥行きを生み出します。
半透明のボーダー
固定の 16 進数値ではなく、アルファ値に基づくボーダーカラーを使用します。半透明のボーダーは、どのような背景にも自動的になじみます。
/* Hardcoded (breaks on different backgrounds) */
border: 1px solid #e5e5e5;
/* Adaptive (works on any background) */
border: 1px solid rgba(0, 0, 0, 0.12);
/* Dark mode equivalent */
border: 1px solid rgba(255, 255, 255, 0.12);
これにより、多くの場合、ライト/ダーク用に個別のボーダートークンを用意する必要がなくなります。
CSS 疑似要素のパターン
DOM の複雑さを軽減する、ネイティブの疑似要素とセレクターです。
::markerでカスタムのリストマーカーを作成します。background-image を使った回避策の代わりになります。
li::marker { color: var(--muted-foreground); font-size: 0.8em; }
::first-lineで文字組みを調整します。ビューポートが変わると、自動的に適応します。
.article p:first-of-type::first-line {
font-variant-caps: small-caps;
font-weight: 500;
}
::backdropでダイアログやポップオーバーのオーバーレイを作成します。追加のオーバーレイ用 div の代わりになります。
dialog::backdrop { background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); }
- 入力欄のスタイル設定には
::placeholderを使用します(実際の入力テキストよりコントラストを低く保ちます)。
視覚上の微調整
目には必要でも、数値計算だけでは補えない小さな調整です。
- 再生ボタンのアイコンは、中央に見せるために右へ 1~2px オフセットする必要があります(三角形の視覚的な重心は、バウンディングボックスの左側にあります)。CSS の追加調整を避けるには、SVG 内でオフセットを調整するのがおすすめです。
- テキストとアイコンを含むボタンは、アイコン側のパディングを小さくする必要があります。バウンディングボックス内のアイコンの余白によって視覚的なバランスが崩れるため、補正として、その側のパディングを 2~4px 減らします。
- 正方形のコンテナ内の円形アイコンは、小さく見えます。円形アイコンのサイズを約 5% 大きくするか、コンテナのパディングを小さくします。
- チェックボックスやラジオボタンの横のテキストは、小さいサイズでは上マージンを 1~2px マイナスにする必要があります。ベースラインが視覚的な中心より上に位置するためです。
- ピル型のバッジ(
rounded-full)は、ボーダー半径から想定されるよりも水平方向のパディングが少し多く必要です。2~4px 追加します。 - ボタン内のすべて大文字のテキストは、縦方向の中央からずれて見えます。下のパディングを 1px 増やすか、
leading-noneを使い、パディングで手動調整します。
17. 防御的 CSS
よくあるレイアウト崩れを防ぐパターンです。
/* Prevent flex items from overflowing */
.flex-child { min-width: 0; overflow-wrap: break-word; }
/* Prevent images from breaking layout */
img { max-width: 100%; display: block; object-fit: cover; }
/* Prevent scroll chaining through modals */
.modal { overscroll-behavior: contain; }
/* Always use auto overflow, not scroll */
.scrollable { overflow-y: auto; }
/* Reserve scrollbar space to prevent layout shift */
.page { scrollbar-gutter: stable; }
/* Use min-height, not height, for variable content */
.hero { min-height: 400px; } /* NOT height: 400px */
/* Use min-width, not width, for buttons */
.button { min-width: 100px; } /* NOT width: 100px */
/* Fallback backgrounds for failed images */
.card-image { background-color: var(--muted); }
/* Fallback for CSS variables */
.element { width: var(--sidebar-width, 250px); }
/* Prevent flex containers from hiding overflow */
.grid-child { min-width: 0; }
/* Use auto-fill to prevent stretching with few items */
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
/* Sticky elements in grid need align-self */
.sidebar { align-self: start; position: sticky; top: 1rem; }
/* Single-line text truncation */
.truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Multi-line text truncation (e.g., 3 lines) */
.line-clamp-3 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
/* Safe area padding for full-bleed layouts on notched devices */
.safe-area { padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
/* Never use arbitrary z-index values. Always map to a token layer. */
/* z-base: 0, z-dropdown: 1000, z-sticky: 1100, z-modal: 1200, z-popover: 1300, z-toast: 1400 */
18. モダン CSS リセット
どのプロジェクトでも使える、妥当な出発点です。
/* 1. Use border-box for everything */
*, *::before, *::after { box-sizing: border-box; }
/* 2. Remove default margins */
* { margin: 0; }
/* 3. Enable smooth keyword animations (respects motion prefs) */
@media (prefers-reduced-motion: no-preference) {
html { interpolate-size: allow-keywords; }
}
/* 4. Accessible body defaults */
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* 5. Responsive media */
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}
/* 6. Form elements inherit fonts */
input, button, textarea, select { font: inherit; }
/* 7. Prevent text overflow */
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
/* 8. Better text wrapping */
p { text-wrap: pretty; }
h1, h2, h3, h4, h5, h6 { text-wrap: balance; }
/* 9. Root stacking context */
#root, #__next { isolation: isolate; }
19. 予測的プリフェッチ
カーソルの軌跡を分析してユーザーがクリックする前にコンテンツを読み込み、体感レイテンシーを 100~200ms 短縮します。
軌跡予測とホバーの比較
標準的なホバーベースのプリフェッチ(onMouseEnter)は、ユーザーの動きが止まり、すでに要素上にカーソルがある状態になるまで待機します。軌跡予測では、カーソルが移動中のうちに進行先を計算し、ホバーベースの方式で無駄になる約100~200msを取り戻します。
// Hover-based (too late — fires after the user has arrived)
<Link href="/about" onMouseEnter={() => router.prefetch("/about")}>About</Link>
// Trajectory-based (fires while cursor is still moving toward the element)
const { elementRef } = useForesight({
callback: () => router.prefetch("/about"),
hitSlop: 20, // expand invisible prediction area by 20px
name: "about-link",
})
<Link ref={elementRef} href="/about">About</Link>
ルール
- ビューポートではなく、意図に基づいてプリフェッチします。 表示されているリンクをすべてプリフェッチしないでください。ほとんどのユーザーは、表示されているリンクの大半をクリックしません。軌跡予測やホバーを使って、意図を判断してください。
hitSlopを使って予測を早めに開始します。 要素の周囲に見えない領域(例:20px)を広げると、見た目を変えずに早く読み込みを開始できます。- タッチデバイスでは適切にフォールバックします。 タッチにはカーソルの軌跡がありません。
touchstartまたはビューポートベースの方式にフォールバックしてください。 - キーボード操作によるナビゲーションでもプリフェッチします。 フォーカスの変化を監視し、ユーザーが目的の項目に数回のTab操作で到達できる位置に来たらプリフェッチしてください。
- 選択的に使用します。 予測プリフェッチは、データ量の多いダッシュボード、APIの応答が遅い複数ページのアプリ、Eコマースで特に効果を発揮します。ナビゲーションが瞬時に行われる静的サイトでは使用しないでください。
参考:ForesightJS
20. オーディオフィードバックとサウンドデザイン
オーディオは装飾ではなく、UIのツールです。適切に使えば、操作を強化し、物理的に操作している感覚を生み出します。不適切に使えば、ユーザーを不快にさせます。
サウンドを使う場面
| 操作 | サウンド? | 理由 |
|---|---|---|
| 決済の成功 | はい | 重要な確認 |
| フォームの送信 | はい | ユーザーに安心感を与える必要があります |
| エラー状態 | はい | 見逃されてはなりません |
| 通知 | はい | ユーザーが画面を見ていない場合があります |
| 重要なボタン(例:送信) | 場合による | マイルストーンとなる操作に限ります |
| タイピング | いいえ | 頻度が高すぎます |
| ホバー | いいえ | 装飾的で、情報としての価値がありません |
| スクロール | いいえ | 頻度が高すぎます |
| ナビゲーション | いいえ | キーボード操作によるナビゲーションで音が多くなります |
アクセシビリティのルール
- すべてのサウンドには視覚的な代替表現が必要です。 サウンドは視覚的なフィードバックを補うものであり、置き換えるものではありません。
- サウンドを無効にする切り替え機能を用意します。 ユーザーの設定を尊重してください。
prefers-reduced-motionを尊重します――動きとあわせてサウンドも抑えるか、無効にしてください。- サウンドの重みを操作の重みに合わせます。 切り替えには小さなクリック音、購入には成功のチャイムを使います。チェックボックスにファンファーレを鳴らしてはいけません。
- サウンドの長さを操作の長さに合わせます。 クリック音は5~15ms、確認音は約200msです。即時に終わる操作に長いサウンドを使うと不自然に感じられます。
- 罰するのではなく、知らせます。 入力検証エラーには穏やかな通知音を使い、耳障りなブザー音は避けてください。
実装
// Preload sounds to avoid first-play delay
const sounds = {
success: new Audio("/sounds/success.mp3"),
error: new Audio("/sounds/error.mp3"),
click: new Audio("/sounds/click.mp3"),
}
Object.values(sounds).forEach(a => { a.volume = 0.3; a.load() })
function playSound(name: keyof typeof sounds) {
sounds[name].currentTime = 0 // reset so rapid re-triggering works
sounds[name].play()
}
- デフォルトの音量:0.3(30%)。ユーザーを驚かせるため、1.0にはしないでください。
- すばやく再トリガーできるよう、再生のたびに
currentTime = 0にリセットします。 - 初回のトリガー時に遅延しないよう、オーディオファイルをプリロードします。
Web Audio APIによるプロシージャルサウンド
オーディオファイルを使わずにUIサウンドを作成する場合は、クリック音やトーンをプログラムで生成します。
// Singleton AudioContext (never create per sound)
let ctx: AudioContext | null = null
function getCtx() { return ctx ??= new AudioContext() }
// Click sound: filtered noise burst (5-15ms)
function playClick() {
const ctx = getCtx()
if (ctx.state === "suspended") ctx.resume()
const buffer = ctx.createBuffer(1, ctx.sampleRate * 0.008, ctx.sampleRate)
const data = buffer.getChannelData(0)
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / 50)
}
const source = ctx.createBufferSource()
source.buffer = buffer
const filter = ctx.createBiquadFilter()
filter.type = "bandpass"
filter.frequency.value = 4000 // 3000-6000Hz for crisp clicks
filter.Q.value = 3 // 2-5 for focused but natural
const gain = ctx.createGain()
gain.gain.setValueAtTime(0.3, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.05)
source.connect(filter).connect(gain).connect(ctx.destination)
source.start()
source.onended = () => { source.disconnect(); filter.disconnect(); gain.disconnect() }
}
// Tonal sound: oscillator with pitch sweep (confirmations, pops)
function playTone() {
const ctx = getCtx()
if (ctx.state === "suspended") ctx.resume()
const t = ctx.currentTime
const osc = ctx.createOscillator()
osc.frequency.setValueAtTime(400, t)
osc.frequency.exponentialRampToValueAtTime(600, t + 0.04) // upward sweep
const gain = ctx.createGain()
gain.gain.setValueAtTime(0.3, t)
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.08)
osc.connect(gain).connect(ctx.destination)
osc.start(t)
osc.stop(t + 0.08)
osc.onended = () => { osc.disconnect(); gain.disconnect() }
}
Web Audioの重要なルール:
- 単一の
AudioContextを再利用します――サウンドごとに作成しないでください(ブラウザーの上限は約6です)。 - 再生前に、中断されたコンテキストを再開します(
ctx.state === "suspended")。ブラウザーはユーザーが操作するまでAudioContextを中断します。 - メモリーリークを防ぐため、再生後にノードを切断します。
- 減衰には指数ランプを使います(線形ではなく)。目標値は
0.001にします(0にはしないでください。指数ランプではゼロに到達できません)。 - パーカッシブなサウンド(クリック音、タップ音)にはノイズを使います。トーンのあるサウンド(ポップ音、確認音)にはオシレーターを使い、音楽的な響きを出すためにピッチスイープを加えます。
| ユーザーのフィードバック | 修正方法 |
|---|---|
| 「きつすぎる」 | フィルター周波数を下げ、Qを小さくする |
| 「こもりすぎている」 | フィルター周波数を上げる |
| 「長すぎる」 | バッファーの長さを短くし、減衰を速くする |
| 「唐突に途切れる」 | 線形減衰ではなく、指数減衰を使う |
| 「より機械的に」 | Qを大きくし、減衰を速くする |
| 「より柔らかく」 | ゲインを下げ、三角波を使う |
クイックリファレンス:一般的な値
| プロパティ | 推奨されるデフォルト値 |
|---|---|
| 本文のフォントサイズ | 16px (1rem) |
| 本文の行の高さ | 1.5 |
| 見出しの行の高さ | 1.1 - 1.25 |
| コンテンツの最大幅 | 65ch |
| 余白の基本単位 | 4px |
| 角丸(sm) | 4px |
| 角丸(md) | 8px |
| 角丸(lg) | 12px |
| 角丸(full) | 9999px |
| 角丸(shadcn のデフォルト) | 0.625rem (10px) |
| トランジション(高速) | 150ms ease-out |
| トランジション(通常) | 250ms ease-out |
| トランジション(低速) | 500ms ease-out |
| タッチ領域の最小サイズ | 44px(48px を推奨) |
| 最小コントラスト比(テキスト) | 4.5:1 |
| 最小コントラスト比(大きなテキスト) | 3:1 |
| 最小コントラスト比(UI 要素) | 3:1 |
| 行の最大長 | 60-75 文字が最適、最大 80 文字 |
| ボタン/入力欄の高さ | 32-36px(標準)、40px(快適) |
| アイコンのサイズ | 16px(高密度)、20px(デフォルト)、24px(目立たせる場合) |
| ボタンのパディング比率 | 横 2x :縦 1x |
| 影のぼかし比率 | オフセット距離の 2x |
| スピナーの表示遅延 | 表示まで 400ms |
| システムの応答時間目標 | < 400ms(ドハティのしきい値) |
| 色の形式 | OKLCH(推奨)、HSL(代替) |
| フォーカスリング | 2px solid、オフセット 2px |
| Z-index レイヤー | ドロップダウン:1000、スティッキー:1100、モーダル:1200、ポップオーバー:1300、トースト:1400 |