はじめに:UI/UXは「センス」ではない。「工学」である
普段バックエンドやフロントエンドのロジックを書いているエンジニアが、自前でUIを作ったり、既存の画面を修正したりするとき、こんな経験はないでしょうか。
- 「デザインツールを使わずに Tailwind CSS でそれっぽい画面を作ったけれど、使ってみるとなぜか操作しづらい」
- 「スマホやタブレットで触ると、ボタンが押しにくくて隣の要素を誤タップしてしまう」
- 「画面にボタンをたくさん配置したら、どこを押せばいいのか初見で迷ってしまう」
- 「入力欄とラベルを並べただけなのに、どのラベルがどの入力欄に対応しているのか分かりにくい」
- 「セレクトボックスを開いたら、テーブルやカードの枠(overflow: hidden)で下半分がバッサリ切れて見えない」
- 「フォーカスした瞬間に、ボタンやフォームのサイズが微妙にビクッと揺れる」
- 「ダークモードに対応したはずなのに、ホバーしたボタンが背景の黒に溶けて見えなくなる」
私自身、長年コードを書いてきて強く実感していることがあります。それは、
「UI/UXはデザイナーの生まれ持ったセンスや感性ではない。人間の脳の認知限界と身体の物理的制約に対応する『工学(エンジニアリング)』である」
ということです。
コードにアルゴリズムや計算量($O(n)$)の理論があるように、人間の認知や指の動き、視線の移動、ディスプレイの光照モデルにも確立された数学モデルと国際標準(WCAG、Apple HIG、Google Material Designなど)が存在します。
この記事では、「デザインの専門知識はゼロ」「CSSは書けるけれどUIの良し悪しを言語化できない」というエンジニアの皆さんが、**最短でプロレベルのUI/UX判断・実装ができるようになるための「理論基石」と「現場の実践コード」**を徹底解説します。
1. 身体の物理制約:フィッツの法則(Fitts's Law)と不可視タップ領域
なぜ小さなスイッチやアイコンボタンはスマホで誤爆するのか?
人間工学において最も有名な法則の一つが、ポール・フィッツが提唱した**「フィッツの法則(Fitts's Law)」**です。
ターゲットを指やマウスポインターで指し示してタップするまでの移動時間 $T$ は、以下の数式で表されます。
$$T = a + b \log_2\left(1 + \frac{D}{W}\right)$$
- $D$(Distance):現在のポインター/指の位置からターゲットまでの距離
- $W$(Width / Area):ターゲットの幅・面積
- $a, b$:定数
この数式が言っている本質は極めてシンプルです。
「ターゲットが小さければ小さいほど、そして離れていればいるほど、ユーザーの操作コストと誤操作率は対数関数的に跳ね上がる」
業界標準のサイズ基準
現代のソフトウェアにおいて、この法則は以下の国際標準・OSガイドラインの絶対数値として落とし込まれています。
| 規格 / ガイドライン | 最小タップ領域(Target Size) | 備考 |
|---|---|---|
| WCAG 2.2 SC 2.5.8 (Level AA) | $24 \times 24\text{px}$ | これ未満はアクセシビリティ不適合 |
| WCAG 2.2 SC 2.5.5 (Level AAA) | $44 \times 44\text{px}$ | 最高水準のユーザビリティ |
| Apple Human Interface Guidelines (HIG) | $44 \times 44\text{pt}$ | iOS / macOS 標準 |
| Google Material Design 3 | $48 \times 48\text{dp}$ | Android / Web 標準 |
プロの設計手法:「見た目のサイズ」と「タップ領域」を分離する
ここでエンジニアが直面するのが「見た目と操作性のジレンマ」です。
「トグルスイッチや小さなチップ、テーブル行のゴミ箱アイコンを $44\text{px}$ にしたら、画面が巨大なパーツだらけで野暮ったくなってしまう!」
そこでプロが使う業界標準のテクニックが、**「Hit Target Extension(当たり判定拡張テクニック)」**です。
見た目は $24\text{px}$ や $26\text{px}$ のコンパクトな洗練されたデザインを保ちながら、CSS の疑似要素 ::before を使って目に見えない透明なクリック領域だけを $44\text{px}$ 以上に拡張します。
// ❌ Bad: 見た目通り 24px しか当たり判定がなく、タッチ端末で誤タップが頻発する
<button className="h-6 w-11 rounded-full bg-accent">
<span className="h-5 w-5 rounded-full bg-white" />
</button>
// ⭕ Good: 見た目は 24px だが、不可視の ::before で 44px の熱区を完全確保!
<button className="relative h-6 w-11 rounded-full bg-accent before:absolute before:-inset-2.5 before:content-['']">
<span className="h-5 w-5 rounded-full bg-white" />
</button>
この before:-inset-2.5(上下左右に約 10px ずつ透明な当たり判定を広げる)だけで、フィッツの法則をクリアし、誤タップのストレスを劇的にゼロへ近づけることができます。
2. 脳の処理限界:ヒックの法則(Hick's Law)と視覚的階層(Visual Hierarchy)
画面にボタンが並ぶと、ユーザーはなぜフリーズするのか?
画面ヘッダーやフォームの末尾に、同じような見た目のボタンがずらりと並んでいる画面を見たことはないでしょうか。
心理学者ウィリアム・ヒックが発見した**「ヒックの法則(Hick's Law)」**によれば、人間が複数の選択肢から行動を1つ決定するまでにかかる時間(反応時間 $RT$)は、選択肢の数 $n$ に応じて増大します。
$$RT = b \log_2(n + 1)$$
選択肢が 1 つなら即決できますが、同じ強さの選択肢が 4 つ、5 つと増えるごとに、脳のワーキングメモリに負荷(認知負荷)がかかり、ユーザーは**「どれを押すべきか分からない」**という認知フリーズ状態に陥ります。
アクションの3段階設計(Visual Hierarchy)
この認知負荷を解決するのが**「視覚的階層(Visual Hierarchy)」**です。1つの視覚領域(ビューポート)において、ユーザーに一番取ってほしい主行動(CTA: Call to Action)は原則として「1つ」に絞ります。
【High Emphasis】 Primary Button (塗りつぶし青など) ─ 画面に原則 1 つ。「保存」「作成」
↓
【Medium Emphasis】 Secondary Button (枠線のみ) ─ 並列の通常操作。「キャンセル」「戻る」
↓
【Low Emphasis】 Ghost / Text Button(背景も枠線もなし) ─ 補助的な操作。「再読込」「詳細を開く」
// ❌ Bad: どれも枠線付きで同じ強調度。どれが主操作か一瞬迷う
<div className="flex gap-2">
<Button variant="secondary">再読込</Button>
<Button variant="secondary">CSVエクスポート</Button>
<Button variant="secondary">下書き保存</Button>
<Button variant="primary">公開する</Button>
</div>
// ⭕ Good: 視覚的重みが明確に階段状になり、Primary に視線が吸い寄せられる
<div className="flex gap-2">
<Button variant="ghost" icon={RefreshCw}>再読込</Button>
<Button variant="ghost" icon={Download}>エクスポート</Button>
<Button variant="secondary">下書き保存</Button>
<Button variant="primary">公開する</Button>
</div>
破壊的操作(Danger)の罠:「アラート疲労(Alert Fatigue)」
「削除操作だから赤ボタンにしておこう」と、リストの解除や一時的な下書き破棄まで真っ赤なベタ塗り(Filled Danger)にしていませんか?
危険警告が画面中に溢れると、人間の脳は刺激に慣れて麻痺します。これを医学や心理学で**「アラート疲労(Alert Fatigue)」**と呼びます。赤ボタンに慣れ切ったユーザーは、本当にとんでもない破滅的データ削除ダイアログが出たときも、警告を読まずに赤ボタンをポンと押してしまいます。
- 高危険度(High-Emphasis Danger: 赤ベタ塗り): データベース物理削除、アカウント完全抹消(必ず確認モーダルとセット)。
- 中危険度(Medium-Emphasis Danger: 赤枠線): プロジェクトからの除外、共有リンクの無効化。
- 低危険度(Low-Emphasis Danger: 赤文字 Ghost): 検索履歴のクリア、フォームの入力リセット。
危険度に応じた階層化を行うことが、ユーザーを重大インシデントから守る防御的UX(Defensive UX)です。
3. 空間と境界の幾何学:余白(Spacing)・ボーダー(Border)・ゲシュタルト心理学
ボタンの形や色と同じくらい、あるいはそれ以上に画面の「使いやすさ」を決定づけるのが、「余白(Gap/Padding)」と「境界線(Border)」の設計です。
ゲシュタルト心理学「近接の法則(Law of Proximity)」と余白階層
人間の視覚認知には、**「物理的に近いもの同士を自動的にひとつのグループ(関連するもの)と見なす」**という強烈な本能があります(ゲシュタルト心理学・近接の法則)。
フォームを作るとき、エンジニアがよくやってしまうのが**「ラベルの迷子(Label Detachment Problem)」**です。
❌ Bad: 余白が均等、または逆転している例
[ ユーザー名 ] ← ラベル
↓ (余白 16px)
[____________] ← 入力欄 1
↓ (余白 16px)
[ メールアドレス ] ← ラベル 2
※ ラベル2が、上の入力欄1と下の入力欄2のちょうど中間にあり、
「このラベルは上のか?下のか?」と脳が一瞬迷う!
プロのUI設計では、**「余白の不等式(Spacing Hierarchy)」**を数学的に厳格に守ります。
$$\text{内部・関連要素の距離 } (A) < \text{フィールド間の距離 } (B) < \text{グループ間の距離 } (C) < \text{セクション間の距離 } (D)$$
- $A$(極近: 4px〜6px): ラベルから直下の入力欄、アイコンとテキストの間隔
- $B$(中: 16px〜20px): 入力欄から次の入力欄(ラベル)までの間隔
- $C$(大: 24px〜32px): フォームブロックやカード同士の間隔
- $D$(特大: 32px〜48px): 画面内の大きなセクション間隔
// ⭕ Good: 近接の法則が働き、誰が見ても一瞬でグループが脳に伝わる
<div className="space-y-5"> {/* フィールド間は 20px (B) */}
<div className="space-y-1.5"> {/* ラベルと入力欄は 6px (A) */}
<label className="text-sm font-medium text-fg">ユーザー名</label>
<input className="w-full h-9 rounded-md border border-border-control px-3" />
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-fg">メールアドレス</label>
<input className="w-full h-9 rounded-md border border-border-control px-3" />
</div>
</div>
8pt / 4pt グリッドシステムの数学的必然性
「余白に 7px とか 13px を適当に使ってはいけないの?」
結論から言うと、**奇数ピクセルの余白や寸法は、デザインシステムにおいて「悪」**です。業界ではすべての余白とコントロール高さを 8 の倍数(細かい部分は 4 の倍数) で統一する「8-Point Grid System」が標準となっています。
-
なぜ 8pt なのか?
現代のあらゆるディスプレイ解像度(Retina 2x, 3x, 1080p, 4K)は、偶数、特に 8 の倍数で割り切れるように設計されています。奇数の px(7px, 11px など)を指定すると、スマホや高解像度モニタでレンダリングされる際に「サブピクセル補間(小数点のピクセル計算)」が発生し、線や文字の輪郭が微妙にぼやける(Anti-aliasing Blur) 現象が発生します。 -
基本スケール:
4px,8px,12px,16px,20px,24px,32px,40px,48px,64px...
ボーダーの色:装飾線と操作要素の厳格な分離(WCAG 1.4.11)
「枠線なんだから、Tailwind の border-gray-200(薄いグレー)を全部に当てておけば綺麗でしょ?」
これも非常によくある、そして重大なアクセシビリティ違反を引き起こす落とし穴です。
国際標準規格 WCAG 2.1 SC 1.4.11(Non-text Contrast / 非テキストのコントラスト) では、境界線を厳格に 2 種類に分類しています。
┌────────────────────────────────┬────────────────────────────────────────────┐
│ 境界線の種類 │ WCAG 要件 │
├────────────────────────────────┼────────────────────────────────────────────┤
│ 1. 装飾的罫線 (Decorative) │ コントラスト要件なし (1.2:1〜2:1 でも OK) │
│ ・カードの外枠 │ 背景と溶け合っても「機能」は損なわれない │
│ ・テーブルの行区切り線 │ │
├────────────────────────────────┼────────────────────────────────────────────┤
│ 2. 操作コントロール境界 │ 背景に対して【最低 3:1 以上】が必須! │
│ (Control Boundary) │ 3:1 未満だと弱視・色覚多様性・屋外の日射下 │
│ ・入力欄 (TextField) の外枠 │ で「どこに入力欄があるのか認識できない」 │
│ ・Secondary ボタンの輪郭 │ │
│ ・チェックボックス / スイッチ│ │
└────────────────────────────────┴────────────────────────────────────────────┘
薄すぎるグレー(例えば白背景上の #e5e7eb)はコントラスト比が約 1.3:1 しかなく、操作コントロールの輪郭として使うと国際基準違反になります。
- 装飾用トークン:
--color-border(薄い線) - 操作用トークン:
--color-border-control(3:1 以上を保証する濃い線)
この 2 つを明確に使い分けるのがプロのエンジニアリングです。
ボーダーの太さ(Border Width)と「1px の罠」
フォームのバリデーションエラー時や、フォーカスが当たった時、線の太さを太くして目立たせようとしていませんか?
/* ❌ 画面がガタつく致命的アンチパターン */
input:focus {
border-width: 2px; /* 1px から 2px に太くする */
}
ブラウザのボックスモデルにおいて、border-width を増やすと、要素の外形寸法が 2px 膨らむか、内部のコンテンツが 2px 押し縮められます。
その結果、フォーカスした瞬間に隣接するボタンやテキストが「ビクッ」と動くレイアウトシフト(Jitter) が発生します。
-
プロの解決策:
太さを変えるときはborderではなく、レイアウトに一切影響を与えないoutline(外枠) またはbox-shadow(影による疑似ボーダー) を使います!
/* ⭕ Good: 寸法は 1px も動かさず、2px の鮮明なフォーカスリングを出す */
input {
border: 1px solid var(--color-border-control);
outline: none;
}
input:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
ボタンの「1px 透明ボーダー」の秘密
優れたデザインシステムのボタンコードを見ると、Primary ボタンに枠線がないのに、わざわざ border border-transparent(1px の透明な線)が指定されていることがあります。
なぜ透明な線を引くのでしょうか?
答えは、「枠線付きの Secondary ボタン(1px 枠線)と横並びにしたとき、高さが 1px ズレるのを防ぐため」 です。
Primary に枠線を書かないと高さ 36px、Secondary は枠線(上下 1px × 2)で 38px になってしまい、並べた瞬間に 1px の段差(ガタつき)が生まれてしまいます。透明な 1px をあらかじめ予約しておくことで、全バリアントの物理寸法を完全一致させているのです。
複合パーツの接合:入力グループ(Input Group)の二重ボーダー問題
検索バーなどで「入力欄の右隣に、ピタッと検索ボタンをくっつける」UI(Input Group)を作るとき、単に 2 つの要素を並べるとどうなるでしょうか。
[ 入力欄 (右角丸 6px, 右枠 1px) ] [ 検索ボタン (左角丸 6px, 左枠 1px) ]
↓↓
1. 接合部の枠線が「1px + 1px = 2px」の不自然に太い線になる!
2. 角丸同士が衝突して、接合部に不格好な「くびれ・隙間」ができる!
プロの CSS 設計では、以下の 3 つのテクニックでシームレスに一体化します。
-
角丸の打ち消し: 左の要素は
rounded-r-none、右のボタンはrounded-l-none。 -
マイナスマージンによる境界線の重なり: ボタンに
-ml-px(margin-left: -1px)を指定して、1px の枠線をピタリと重ねる(Collapsed Border)。 -
フォーカス時の z-index 引き上げ: 重ねたままだと、左の要素にフォーカスが当たったとき右端のリングが隠れるため、
:focus時にrelative z-10で前面に出す。
// ⭕ Good: 完璧な一体型 Input Group
<div className="flex">
<input
type="text"
placeholder="検索キーワード..."
className="h-9 w-64 rounded-l-md rounded-r-none border border-border-control px-3 focus:relative focus:z-10 focus:outline-2 focus:outline-focus-ring"
/>
<button
type="button"
className="-ml-px h-9 rounded-l-none rounded-r-md border border-border-control bg-surface px-4 hover:bg-surface-hover focus:relative focus:z-10 focus:outline-2 focus:outline-focus-ring"
>
検索
</button>
</div>
4. レイヤーと浮遊の幾何学:ポップオーバー切断バグ(Overflow Clipping)の根絶
セレクトボックスの下半分が消える悪夢
フロントエンド開発で非常によく遭遇する悪名高いバグが、「親コンテナの overflow: hidden によるポップオーバーの切断(Overflow Clipping)」 です。
カスタムセレクトボックスやドロップダウンメニューを実装したとき、コンポーネント自身の DOM 内部に position: absolute; top: 100% でメニューを展開してしまうと何が起きるでしょうか。
┌──────────────────────────────────────────────┐
│ モーダルやカード (overflow: hidden / auto) │
│ │
│ [ セレクトボックス ▼ ] │
│ ┌──────────────────────┐ │
│ │ 選択肢 1 │ │
│ │ 選択肢 2 │ │
└─┼──────────────────────┼─────────────────────┘
│ 選択肢 3 (消滅!) │ ← 親の境界線でバッサリ切られて見えなくなる!
│ 選択肢 4 (消滅!) │
└──────────────────────┘
親要素(DataTable のセル、ダイアログ、スクロール可能なカード)が overflow: hidden や overflow: auto を持っていると、absolute で展開したメニューはその境界で容赦なくクリッピング(非表示)されます。
解決策:Portal 化と視口翻転(Viewport Flip)
業界標準の解決策は、メニューの描画先をコンポーネントツリーから切り離し、createPortal を使って document.body の直下にテレポート描画することです。
さらに、画面下部に置かれたセレクトを開いた際、画面外に突き抜けないよう**「上下反転(Flip)」**の幾何計算を組み込みます。
// ⭕ Good: createPortal と幾何計算による堅牢なドロップダウン
import { createPortal } from "react-dom";
export function FloatingDropdown({ triggerRef, open, children }) {
const [coords, setCoords] = useState({ top: 0, left: 0, placement: "bottom" });
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const menuHeight = 200; // 想定メニュー高さ
// 画面下に十分な余白がなければ、上側に展開(Flip)する!
const shouldFlip = spaceBelow < menuHeight && rect.top > menuHeight;
setCoords({
left: rect.left,
top: shouldFlip ? rect.top - menuHeight - 4 : rect.bottom + 4,
placement: shouldFlip ? "top" : "bottom",
});
}, [open]);
if (!open) return null;
return createPortal(
<div
style={{ position: "fixed", top: coords.top, left: coords.left }}
className="z-50 w-64 rounded-md border border-border bg-surface shadow-lg"
>
{children}
</div>,
document.body // ← 親の overflow を完全に脱出!
);
}
さらに、W3C APG Combobox 規格に準拠し、メニューが開いているときに英数字をタイピングすると該当項目へジャンプする Typeahead Search(頭文字ジャンプ) を実装することで、マウスを使わない高速なフォーム入力を実現します。
5. レスポンシブの死角:狭小画面におけるドロワー変換とテーブルのカード化
「PCファースト」で作った画面がスマホで爆死する理由
多くの業務システムはデスクトップ環境(1280px / 1920px)で開発されます。その結果、スマホ(375px)で開いた時に以下の 2 大破綻が発生します。
-
サイドバーがメイン領域を押し潰す:
左側のサイドバーが固定幅(252px)のまま表示され、右側のメイン領域が幅 123px に圧迫されて文字が1行1文字で縦並びになる。 -
テーブルが右画面外に無限スクロール:
10 列あるテーブルがそのまま描画され、一番右端にある超重要操作「編集」「削除」ボタンが画面外に消え去り、ユーザーが気づけない。
① オフキャンバスドロワー(Off-canvas Drawer)への自動変換
画面幅が lg(1024px)未満になった瞬間に、サイドバーを通常フローから退避させ、「暗幕(Backdrop Scrim)付きの引き出しドロワー」 へ自動トランスフォームさせます。
// ⭕ Good: レスポンシブな AppShell の構造
export function AppShell({ sidebar, children }) {
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
return (
<div className="flex h-screen w-full overflow-hidden">
{/* デスクトップでは常時表示、モバイルでは非表示 */}
<div className="hidden lg:block">{sidebar}</div>
{/* モバイル用ドロワー(暗幕 Backdrop + スライドイン) */}
{mobileDrawerOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setMobileDrawerOpen(false)} />
<div className="fixed inset-y-0 left-0 w-64 bg-surface shadow-2xl animate-in slide-in-from-left">
{sidebar}
</div>
</div>
)}
{/* メイン領域 */}
<main className="flex-1 overflow-y-auto">
{/* モバイルでのみ表示するハンバーガーボタン (44px) */}
<div className="flex h-14 items-center border-b px-4 lg:hidden">
<button
onClick={() => setMobileDrawerOpen(true)}
className="h-11 w-11 flex items-center justify-center rounded-md"
aria-label="メニューを開く"
>
<Menu size={20} />
</button>
</div>
{children}
</main>
</div>
);
}
② テーブルの「カードリスト化」または「操作列固定(Sticky Action)」
スマホ画面でデータテーブルを快適に見せる手法は 2 つあります。
-
手法 A:カードリストへの自動変形(Card Transformation):
CSS Grid や Container Queries を使い、狭小画面では<table>の行(<tr>)をひとつの独立したCardとして縦積みに再配置する。 -
手法 B:操作列の固定(Sticky Actions Column):
横スクロールを許容する場合でも、一番右端のアクション列(編集・削除)だけをsticky right-0で固定し、背景にグラデーションシャドウを敷いて、常に操作ボタンが視界に入るように保護する。
6. 物理と光のモデル:ダークモードにおける「明度(Luminance)」の真実
浅色モードのHoverは「暗く」、深色モードのHoverは「明るく」する
ダークモードを実装するとき、Tailwind や CSS で以下のような書き方をしていないでしょうか。
/* ❌ 浅色・深色の両方で黒(#000)を混ぜて暗くしてしまう */
.button-primary:hover {
background-color: color-mix(in srgb, var(--accent) 88%, #000);
}
これは浅色モードでは正解ですが、ダークモードでは致命的なアンチパターンです。現実世界の物理モデルを考えてみましょう。
-
浅色モード(白背景):
背景が真っ白なので、上にある要素は**「影(Shadow)」**を落とすことで手前に浮いて見えます。押下やホバーで少し暗く(Darken)なると、コントラストが強まり視認性が上がります。 -
深色モード(黒背景):
背景が真っ黒(neutral-950など)なので、影を落としても物理的に何も見えません。
Material Design 3 や Apple HIG におけるダークモードの鉄則は、**「Elevation by Luminance(明度による標高表現)」**です。
「ダークモードでは、手前にあるレイヤー(ダイアログ、ポップオーバー)や、活性化した状態(Hover, Active)ほど『白を混ぜて明度を上げる(Lighten)』」
深色背景 (neutral-950)
→ 面 (neutral-900)
→ 浮いたカード (neutral-850)
→ ホバー / ポップアップ (neutral-800: 明度を上げる!)
もしダークモードでホバー時に黒を混ぜてしまうと、ボタンが暗い背景の中にズブズブと**「沈み込んで同化」**してしまい、ユーザーは「今ボタンに触れているのかどうか」が視覚的に分からなくなってしまいます。
/* ⭕ Good: テーマごとに明度変化の向きを反転させる */
:root {
/* 浅色: ホバーで少し暗く締める */
--button-hover-filter: brightness(0.92);
}
[data-theme="dark"] {
/* 深色: ホバーで光を当てて浮き立たせる! */
--button-hover-filter: brightness(1.15);
}
7. Web標準とアクセシビリティ(WCAG 2.2 & W3C APG)の絶対防衛線
アクセシビリティ(a11y)は「障害者対応」だけの話ではありません。キーボード操作派のエンジニアや、屋外の日差しの下でスマホを使う全ユーザーの体験に直結します。
① Focus Drop(ネイティブ disabled の罠)
「ボタンの二重送信を防ぐために、クリック直後に disabled にする」というコードはよく見かけます。
// ❌ よくあるがアクセシビリティ上の重大バグ
<button disabled={isLoading} onClick={handleSubmit}>
{isLoading ? <Spinner /> : "送信"}
</button>
実は、HTML の仕様上、フォーカスが当たっている要素にネイティブの disabled 属性が付与されると、ブラウザはその要素からフォーカスを強制剥奪し、document.body(ページの最上部)へ吹き飛ばしてしまいます。
スクリーンリーダー利用者はコンテキストを失って迷子になり、キーボードユーザーはもう一度 Tab キーを何十回も連打して元の位置を探さなければなりません。
モダンなデザインシステム(W3C ARIA Authoring Practices, React Aria)では、以下のように aria-disabled="true" と CSS を活用してフォーカスを維持したままクリックを抑制します。
// ⭕ Good: フォーカスを失わせずに連続操作だけを確実に防ぐ
<button
aria-disabled={isLoading}
aria-busy={isLoading}
onClick={(e) => {
if (isLoading) {
e.preventDefault();
return;
}
handleSubmit();
}}
className={cn(isLoading && "pointer-events-none opacity-80 cursor-wait")}
>
...
</button>
② 色覚多様性と冗長符号化(Redundant Encoding)— WCAG 1.4.1
ステータスバッジを作るとき、「成功は緑、失敗は赤、警告は黄色」と色だけで表現していませんか?
男性の約 20 人に 1 人(P型・D型色覚)は、赤と緑の明度・彩度が酷似しており、色だけでは全く判別できません。
WCAG の鉄則は**「色だけで情報を伝えてはならない(Redundant Encoding / 冗長符号化)」です。必ず色に加えて「固有のアイコン」と「テキストラベル」**をペアで配置します。
// ⭕ Good: 色覚多様性でも一目で状態が区別できる StatusBadge
export function StatusBadge({ status }) {
const config = {
success: { icon: CheckCircle, text: "正常終了", bg: "bg-success-subtle text-success-fg" },
error: { icon: AlertCircle, text: "処理失敗", bg: "bg-danger-subtle text-danger-fg" },
warning: { icon: AlertTriangle, text: "要確認", bg: "bg-warning-subtle text-warning-fg" },
}[status];
const Icon = config.icon;
return (
<span className={cn("inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border forced-colors:border-[CanvasText]", config.bg)}>
<Icon size={14} aria-hidden />
<span>{config.text}</span>
</span>
);
}
注:forced-colors:border-[CanvasText] を入れることで、Windows ハイコントラストモードで背景色が透明化された際も、枠線が消えずに視認性を維持できます。
③ Timing Adjustable(トースト通知のホバー一時停止)— WCAG 2.2 SC 2.2.1
数秒で自動的にスッと消えるトースト通知(Toaster)。
長い説明文やエラー詳細を読んでいる最中に、勝手に消えてしまってイラッとした経験はありませんか?
WCAG 2.2 では、**「自動消滅する要素にユーザーがポインターを乗せている(Hover)間、またはフォーカスしている間は、タイマーを一時停止(Pause)しなければならない」**と規定されています。
// ⭕ ToastCard にホバー検知を入れ、タイマーを一時停止する設計
<div
onMouseEnter={() => pauseToastTimer(item.id)}
onMouseLeave={() => resumeToastTimer(item.id)}
onFocus={() => pauseToastTimer(item.id)}
onBlur={() => resumeToastTimer(item.id)}
>
...
</div>
8. 状態遷移の美学:ゼロ・レイアウトシフト(Zero CLS)と Progressive Skeleton
ボタンがローディングで「ビクッ」と動く悪夢
Google の Core Web Vitals にも含まれる重要指標 CLS(Cumulative Layout Shift: 累積レイアウトシフト)。
ボタンを押した瞬間に、アイコンなしボタンにスピナーが突然挿入されるとどうなるでしょうか。
// ❌ Bad: スピナー(16px)+ gap(8px)が出現し、ボタン幅が突然 24px 横に伸びる!
<button className="flex items-center gap-2">
{isLoading && <Spinner className="w-4 h-4" />}
<span>保存する</span>
</button>
ボタンの横幅がピョンと跳ね上がり、隣にある「キャンセル」ボタンの位置までズレます。ユーザーは「画面が壊れた」と感じ、隣のボタンを押し間違える原因になります。
これを防ぐのが**「絶対配置オーバーレイ方式」**です。
// ⭕ Good: スピナーを絶対配置(absolute)で重ねることで、ボタン幅は 1px も揺れない!
<button className="relative flex items-center justify-center">
{isLoading && (
<span className="absolute inset-0 flex items-center justify-center">
<Spinner className="w-4 h-4" />
</span>
)}
{/* ローディング中はテキストの透明度を 0 にして寸法だけキープする */}
<span className={cn(isLoading && "invisible")}>保存する</span>
</button>
「ただの灰色の棒線」を卒業する TableSkeleton
データ取得中に表示するスケルトンスクリーン(Skeleton)。
どんな画面でも「灰色の長方形を3本並べる」だけで済ませていませんか?
ローディングが終わって本物のテーブルやカードが現れた瞬間、画面のレイアウトが激変しては Skeleton の意味がありません。
// ⭕ Good: 読み込み後のテーブル構造を忠実に再現する TableSkeleton
export function TableSkeleton({ rows = 5, cols = 4 }) {
return (
<div className="w-full rounded-md border border-border">
{/* 表ヘッダーの骨格 */}
<div className="flex h-10 items-center border-b bg-surface-sunken px-4 gap-4">
{Array.from({ length: cols }).map((_, i) => (
<div key={i} className="h-4 bg-neutral-200 dark:bg-neutral-800 rounded animate-pulse" style={{ width: `${60 + i * 20}px` }} />
))}
</div>
{/* 表行の骨格 */}
<div className="divide-y divide-border">
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className="flex h-14 items-center px-4 gap-4">
{Array.from({ length: cols }).map((_, c) => (
<div key={c} className="h-4 bg-neutral-100 dark:bg-neutral-850 rounded animate-pulse" style={{ width: c === 0 ? "140px" : "80px" }} />
))}
</div>
))}
</div>
</div>
);
}
**「これから表示される完成画面と同じ骨格をあらかじめ予約しておく」**ことこそが、知覚待ち時間を短縮する Progressive Loading の神髄です。
9. なぜデザインシステムは形骸化(Drift)するのか? — プリミティブ欠落の罠
プロジェクトの ARCHITECTURE.md に「生のボタンやスタイルを手書きするな。共有コンポーネントを使え」と書いてあるのに、実際のコードベースを見ると、各画面に無数の <button className="..."> が手書きされている……という現場は非常に多いです。
なぜ開発者はルールを破ってしまうのでしょうか?
それは開発者の意識が低いからではありません。「デザインシステム側に、業務で絶対に必要となる中間プリミティブが存在しないから」 です。
【デザインシステムの欠落構造】
提供されているもの: [ Button ] [ TextField ] [ DataTable ]
↑
【巨大な溝】 ← 開発者が手書きで埋めざるを得ない!
↓
業務で必要なもの: [ テーブル行タイトル選択ボタン ]
[ カード全体クリック可能な選択肢 ]
[ 前後アイコン付き検索インプット ]
- テーブルの行タイトルをクリックして詳細ドロワーを開きたいのに、
Buttonを使うと大げさな枠線が付いてしまう。 - だから開発者は
<button className="text-left font-medium hover:underline">を手書きする。 - その手書きコードがコピペされ、画面ごとに下線やフォーカスリング、選択色がバラバラに拡散(Drift)していく。
解決策:業務頻出パターンを「原子コンポーネント」へ昇格させる
デザインシステムの管理者は、「ルールを守れ」と怒るのではなく、手書きされているパターンの共通項を吸い上げて、公式コンポーネントとして提供しなければなりません。
-
RowTitleButton: テーブルセル内で行タイトルとして振る舞い、ホバー下線と標準フォーカスリング、aria-currentを備えたインラインボタン。 -
SelectableCard: 選択状態(ラジオ・チェック)を持つカード型の押しボタン。 -
InputGroup: テキスト入力とボタン・単位が隙間なく一体化された合成コンポーネント。
この「中間の業務プリミティブ」が揃って初めて、デザインシステムは現場で100%機能するようになります。
10. 全コンポーネント設計 Before / After まとめ
今回学んだ理論を、実際のコンポーネント設計のビフォーアフターとしてまとめました。
┌───────────────────────┬───────────────────────────────┬──────────────────────────────┐
│ 対象コンポーネント │ ❌ よくあるアンチパターン │ ⭕ 業界標準のベストプラクティス│
├───────────────────────┼───────────────────────────────┼──────────────────────────────┤
│ Button (ボタン) │ 全サイズ文字14px固定 │ 32px(12px字)/36px(14px)/ │
│ │ 深色Hoverで黒を混ぜて沈む │ 40px(16px)の比例排版 │
│ │ ローディングで幅がガタつく │ 深色Hoverは明度UP、絶対配置Spin│
│ │ 枠線なしボタンと並べてズレる │ 1px透明ボーダーで高さを同期 │
├───────────────────────┼───────────────────────────────┼──────────────────────────────┤
│ SelectField (セレクト)│ absolute配置で親のoverflowで │ createPortalで最上位描画 │
│ │ 下半分が切り落とされる │ 画面下では上に自動反転(Flip) │
│ │ キーボード打鍵で候補に飛べない│ Typeahead(頭文字検索)の実装 │
├───────────────────────┼───────────────────────────────┼──────────────────────────────┤
│ Toggle / Switch │ 見た目通り24px~26pxで実装 │ 疑似要素::beforeで不可視の │
│ (スイッチ/チップ) │ スマホで誤タップ多発 │ 44x44pxタップ熱区を確保 │
├───────────────────────┼───────────────────────────────┼──────────────────────────────┤
│ Spacing / Layout │ 余白がバラバラ (7px, 13px等) │ 8pt Gridに厳格準拠 │
│ (余白と間隔) │ ラベルと入力欄の距離が離れすぎ│ 近接の法則: ラベル-入力(6px) │
│ │ 上下の対応関係が脳で迷子になる│ < フィールド間(20px)の不等式 │
├───────────────────────┼───────────────────────────────┼──────────────────────────────┤
│ Border / Outline │ コントロール枠線を薄いグレーに│ WCAG 1.4.11: 操作枠は3:1必須 │
│ (境界線と太さ) │ フォーカス時にborder-2にして │ outlineやbox-shadowを使い │
│ │ 要素寸法をガタガタ動かす │ レイアウト寸法を1pxも動かさない│
├───────────────────────┼───────────────────────────────┼──────────────────────────────┤
│ AppShell / Sidebar │ スマホ(375px)でもPCサイドバー │ 幅<1024pxでドロワーへ自動変換 │
│ (全体レイアウト) │ が画面の半分以上を押し潰す │ 44pxのハンバーガーメニュー常設│
├───────────────────────┼───────────────────────────────┼──────────────────────────────┤
│ DataTable (表) │ モバイルで横スクロール放置 │ スマホではカードリストへ変換 │
│ │ 右端のアクション列が消える │ または右端アクション列を固定 │
├───────────────────────┼───────────────────────────────┼──────────────────────────────┤
│ Toaster (通知) │ 読んでいる最中に勝手に消える │ onMouseEnter/Focusで一時停止 │
│ │ │ 離れたら自動カウントダウン再開│
├───────────────────────┼───────────────────────────────┼──────────────────────────────┤
│ Skeleton (ローディング)│ 画面によらず灰色の長方形3本 │ 完成形と同じ寸法と列数を持つ │
│ │ 読込完了時に激しくCLS発生 │ TableSkeleton / CardSkeleton │
└───────────────────────┴───────────────────────────────┴──────────────────────────────┘
11. エンジニアが今日から使える UI/UX 思考チェックリスト
最後に、あなたがプルリクエスト(PR)を出す前、またはUIコードをレビューするときに、そのまま使える**「UI/UX 思考チェックリスト」**を授けます。
-
[Fitts] タップ領域は足りているか?
- アイコンやスイッチの物理サイズが $44\text{px}$ 未満の場合、
::before等で当たり判定が拡張されているか?
- アイコンやスイッチの物理サイズが $44\text{px}$ 未満の場合、
-
[Proximity] 余白の階層関係(近接の法則)は守られているか?
- ラベルと入力欄の距離(約 6px)が、次の項目までの距離(約 20px)よりも確実に狭くなっているか?
- すべての余白・パディングは 8pt / 4pt グリッドの倍数になっているか(奇数 px が混ざっていないか)?
-
[Border] ボーダーのコントラストと太さは安全か?
- 入力欄やボタンの枠線は背景に対して 3:1 以上のコントラストがあるか(薄すぎるグレーにしていないか)?
- フォーカス時やエラー時に
border-widthを増やして要素をガタつかせていないか(outlineやbox-shadowを使っているか)?
-
[Clipping] ポップオーバーが親コンテナで切られていないか?
- ドロップダウンメニューやツールチップは
createPortalで描画され、画面外への突き抜け(Flip)が考慮されているか?
- ドロップダウンメニューやツールチップは
-
[Responsive] モバイル(375px)で画面が崩れていないか?
- サイドバーがメインコンテンツを押し潰していないか?テーブルの重要操作が画面外に消え去っていないか?
-
[Hick] プライマリアクションは 1 つに絞られているか?
- 画面内に同等の強さのボタンが乱立していないか?補助操作は Ghost やメニューに逃がしているか?
-
[Luminance] ダークモードのホバーで沈んでいないか?
- 暗い背景の上でホバーしたとき、ボタンや行が「明るく浮き上がって」見えるか?
-
[CLS] 状態遷移で画面がガタついていないか?
- ボタンがローディングになった時、横幅が変わっていないか?スケルトンは完成形と同じ寸法か?
-
[A11y] キーボード操作で迷子にならないか?
- Tab キーで移動した時、
:focus-visibleリングが消えていないか?非同期ボタンに安易にdisabledを付けてフォーカスを吹き飛ばしていないか?
- Tab キーで移動した時、
-
[Redundant] 色だけで状態を伝えていないか?
- 成功(緑)とエラー(赤)のバッジに、色覚多様性に対応した「アイコン」と「テキスト」が併記されているか?
おわりに
UI/UX は「お絵描き」でも「個人の好み」でもありません。
人間という生体の**「認知のクセ」「眼球の動き」「親指の稼働域」「光の物理法則」**を科学的にハックする、極めてロジカルで知的なエンジニアリング領域です。
理論を知っていれば、デザイナーから上がってきたモックの不自然さに気づくことができ、デザインツールのないエンジニア主導の開発でも「なぜか気持ちいい、触っていてストレスのない最高のプロダクト」を作ることができます。
皆さんの日々の開発とプロダクトが、より美しく、より使いやすく進化するための一助になれば幸いです!