ナビゲーションにJSはいらないのか:Checkbox Hackが問い直した制御の所有者

はじめに
ハンバーガーボタンをクリックしてナビゲーションドロワーを開く。
その実装は、だいたいこうなっている。
useState で isOpen を持つ。onClick で setIsOpen(true) を呼ぶ。useEffect で Esc キーとスクロールを検知して閉じる。
私もそうしていた。
それが「当たり前」だと思っていた。useDrawer.ts を書いたときは。
ブラウザはとっくに、チェックボックスの状態を知っていたのに。
この記事をお勧めしない人
- ドロワーがuseStateで動いていれば問題ないと思っている人
- Hydrationギャップを「起動時の一瞬だから許容範囲」と判断している人
- 「実装しないという結論の記事」に価値を見出せない人
もし一つでも当てはまらないなら、読み進める価値があるかもしれません。
【思想衝突】
- Technical Control vs Cognitive Simplicity
- Browser Native vs Partial-JS Complexity
① 問題提起:JS制御のナビゲーションが払っている暗黙のコスト
useState で開閉を管理するナビゲーションドロワーは、動く。テストも書ける。チームにも説明しやすい。
問題は、それが 【Reactのハイドレーション完了を前提にしている】 ことだ。
- 【Hydrationギャップ】:HTML到達 → JS解析 → Reactハイドレーション完了、という順序を経るまでハンバーガーボタンは反応しない。特にCloudflare Workers環境でWorkerのコールドスタートが重なると、ユーザーが最初にボタンを押した瞬間が「死んでいる」時間帯に当たることがある。
- 【責務の誤配置】:チェックボックスの「オン・オフ」という概念はブラウザが何十年も前から持っている。それをReactの
useStateが再実装している。 - 【イベント登録の遅延】:
useEffectはハイドレーション後にしか走らない。つまりEscキーでドロワーを閉じる機能も、ハイドレーション前は存在しない。
これは「間違い」ではない。Reactアプリとしての自然な設計の副産物だ。
② なぜ皆そう作るのか
モーダルやドロワーの開閉状態をJSで管理することは、SPA時代から続く「合理的な選択」だった。状態はコンポーネントツリーで一元管理するべきで、DOMの属性やフォーム要素に状態を持たせるのは「お行儀が悪い」とされていた。
CSS :has() 擬似クラスが登場する前は、CSS単体でチェックボックスの状態に基づいて兄弟要素を制御することは技術的に不可能に近かった。CSSは「上から下、親から子」にしか作用できなかった。兄弟セレクタ(~)はあったが、親要素をまたいでの制御はできなかった。
ReactとHydrationが普及した後も、「インタラクティブなUIはJSが担う」という原則がそのまま継続された。
| 当初解決しようとした問題 | 生まれた副産物 |
|---|---|
| 状態の一元管理と予測可能性 | ハイドレーション完了までUIが反応しない |
| CSSでは兄弟/親をまたぐ制御が不可能だった | JS依存がデフォルト化 |
| Reactコンポーネント内での完結性 | ブラウザが元々持つ状態管理を二重実装 |
誰かが間違えたのではない。その時点での制約に対する答えが、CSSの進化によって問い直されることになった。
③ Checkbox Hack は何を優先したのか
Checkbox Hack は技術的なトリックではなく、【制御の所有者の再定義】だ。
Checkbox Hack が選んだのは「【状態変化をブラウザネイティブのフォーム要素に委ねる】」という原則だ。それは「Reactが状態を持つ」という原則の一部を意図的に後退させることで成立している。
優先した価値
- 【Hydrationギャップの構造的排除】:チェックボックスの状態変化はブラウザネイティブの操作だ。Reactのハイドレーション完了を待たない。HTMLが届いた瞬間からボタンは反応する。
- 【Web標準への準拠】:
input[type="checkbox"]、label、CSS:has()はすべてHTML/CSS仕様。フレームワークが変わっても動く。 - 【JSバンドルサイズの削減】:ドロワーの開閉ロジックがJSから消える。その分だけランタイムが軽くなる。
意図的に後退させた価値
- 【インタラクション機能の完全性】:Escキーで閉じる、スクロールで閉じる——これらはCSS単体では再現できない。キーボードイベントもスクロールイベントも、ブラウザがCSSに公開していない情報だ。
- 【状態の可視性】:
isOpenという変数がコンポーネントツリーに存在しない。「ドロワーが開いているか」をJS側から知るためには、DOMを直接読む必要が生じる。
④ 実装でどう解決しているか
Checkbox Hack の構造:HTMLとCSS
普通なら useState と onClick でドロワーの開閉を実装する。
Checkbox Hack は違う。
<!-- チェックボックスは非表示。labelがハンバーガーボタンとして機能する -->
<input type="checkbox" id="nav-drawer-toggle" class="sr-only" />
<label for="nav-drawer-toggle" aria-label="メニューを開く">
<!-- ハンバーガーアイコン -->
</label>
<!-- チェックボックスとドロワーを同じ親に置く -->
<nav class="nav-drawer">
<!-- ナビゲーションリンク -->
</nav>/* :has() でチェックボックスの状態を親から読む */
body:has(#nav-drawer-toggle:checked) .nav-drawer {
transform: translateX(0);
}
body:has(#nav-drawer-toggle:checked) .nav-overlay {
display: block;
}
/* ラベルのアイコン切り替えも同様 */
body:has(#nav-drawer-toggle:checked) .hamburger-icon {
display: none;
}
body:has(#nav-drawer-toggle:checked) .close-icon {
display: block;
}label をクリックすると、ブラウザがチェックボックスのオン・オフを切り替える。CSS :has() がその状態を読んで、ドロワーの表示・非表示を制御する。JSはゼロ。
守ろうとした価値は「【HTMLが届いた瞬間からUIが反応する】」ことだ。
`:has()` のブラウザサポート
CSS :has() 擬似クラスは2024年時点で主要ブラウザに揃った。
- Chrome 105+(2022年8月〜)
- Firefox 121+(2023年12月〜)
- Safari 15.4+(2022年3月〜)
ClaudeMixが想定するユーザー環境では、サポート外ブラウザはごく少数だ。ただしフォールバック設計は必要になる。
`:has()` が解決した「CSSの上下問題」
従来のCSSは「親から子、上から下」にしか作用できなかった。チェックボックスとドロワーが兄弟要素である場合、input:checked ~ .drawer という隣接兄弟セレクタで制御できた。しかし2要素が親をまたぐ場合(ヘッダー内のinputとbody直下のドロワーなど)は不可能だった。
:has() はこれを「:has() を親要素に適用することで、子の状態を親にバブルアップする」という形で解決した。body:has(input:checked) は「チェックされたinputを持つbody」を意味する。この記述から、bodyの任意の子孫を制御できる。
⑤ 何を捨てたか
Checkbox Hack を採用すると、以下を手放すことになる。
- 【キーボード操作の完全性】:Escキーで閉じる機能はCSS単体では再現できない。
keydownイベントはブラウザがCSSに公開していない。WAI-ARIAのドロワーパターンではEscキー対応は推奨仕様であり、「Escが効かないドロワー」はアクセシビリティ要件を満たせない可能性がある。 - 【スクロールクローズ】:スクロールイベントをCSSで検知する手段はない。ドロワーを開いたままスクロールできる、あるいはオーバーレイでスクロールをブロックする実装が必要になる。
- 【状態の透明性】:
isOpenという変数がJSの世界から消える。「ドロワーが開いているか」を他のコンポーネントやロジックから読む場合、document.getElementById('nav-drawer-toggle').checkedのようなDOM直接参照が必要になる。 - 【ARIA属性の動的更新】:
aria-expandedの値をCSSから変更することはできない。真のアクセシブルなドロワーには、Reactによるaria-expandedの動的更新が依然として必要になる。 - 【理解コスト】:
input:checkedと:has()を組み合わせた設計は、Reactに慣れたエンジニアには直感的でない。「どのイベントで状態が変わるか」の追跡が難しくなる。
【これを許容できるかどうかが、採用判断の核心になる。】
⑥ ClaudeMix への適用
ClaudeMixでも、ナビゲーションドロワーは useDrawer.ts で useState + useEffect により制御している。
現在の useDrawer.ts は以下の責務を持つ。
// app/components/blog/common/useDrawer.ts(現状)
export const useDrawer = () => {
const [isOpen, setIsOpen] = useState(false);
const openDrawer = useCallback(() => setIsOpen(true), []);
const closeDrawer = useCallback(() => setIsOpen(false), []);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeDrawer(); // Escキーで閉じる
}
};
const handleScroll = () => {
closeDrawer(); // スクロールで閉じる
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('scroll', handleScroll);
};
}, [isOpen, closeDrawer]);
return { isOpen, openDrawer, closeDrawer };
};Checkbox Hack を部分適用した場合、どうなるか。
// 仮想的な Partial-JS 状態(採用しない)
// CSSが開閉を担い、JSがEsc/スクロールのみを担う
const useDrawerEscAndScroll = () => {
// チェックボックスのDOM参照が必要
const getCheckbox = () =>
document.getElementById('nav-drawer-toggle') as HTMLInputElement | null;
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
const checkbox = getCheckbox();
if (checkbox?.checked) checkbox.checked = false; // DOMを直接操作
}
};
const handleScroll = () => {
const checkbox = getCheckbox();
if (checkbox?.checked) checkbox.checked = false;
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('scroll', handleScroll);
};
}, []);
};この Partial-JS 状態の何が問題か。
【状態の管理者が二人になる】。CSSはチェックボックスの状態を読んでドロワーを描画する。JSはそのチェックボックスを直接DOM操作で書き換える。Reactの状態管理の外で、DOMの状態とCSSの描画が連動する構造だ。
デバッグ時に「ドロワーが開いているか」の正解を document.getElementById('nav-drawer-toggle').checked を見なければ知ることができない。isOpen という変数はもう存在しない。React DevToolsで状態を追う方法がない。
さらに aria-expanded の更新、アニメーション状態の管理、将来的なドロワー内コンテンツのフォーカストラップ実装を考えると、Partial-JS の複雑さは「useDrawer を React に完全に任せた場合」よりも高くなる可能性が高い。
【ClaudeMixは今回、Checkbox Hack を採用しない。】
理由は明快だ。
useDrawer.tsは既にEscキー・スクロールクローズを含む実装として完成している。動いており、テストが通っており、実装のコストは回収済みだ。- Partial-JS は純粋なCSS制御よりも複雑だ。Hydrationギャップの解消という利益が、Partial-JS の複雑さというコストを上回らない。
- ドロワーの複雑さが現時点では低い。Framer Motion アニメーションや多段コンテンツが入ってくる段階で、構造的な再設計の文脈の中で再評価する。
【評価対象にしたが採用しなかった。採用しない理由が明確になったことが、この記事の成果物だ。】
⑦ この設計は誰向けか
Checkbox Hackが向いている
- コンテンツファーストのサイト:ブログ、ドキュメントサイトなど、インタラクションの複雑さが低く、Hydrationギャップがパフォーマンスに直結するプロジェクト
- SSGやMPAアーキテクチャ:Astro、Eleventy など、Reactのハイドレーションがデフォルトでないフレームワークを使うプロジェクト
- Escキー・スクロールクローズが不要な設計:ドロワーを閉じるにはオーバーレイクリックかナビゲーションリンク選択だけで十分、と割り切れるプロダクト
- アクセシビリティ要件が緩やかなケース:Escキー対応を必須としない内部ツールや実験的サイト
Checkbox Hackが向いていない
- Escキー・スクロールクローズが必須:WAI-ARIAのdialogパターンやdisclosureパターンに準拠する要件がある場合、CSS単体では要件を満たせない
- 状態を他のコンポーネントと共有する必要がある:ドロワーの開閉状態を他のUIロジックから読みたい場合、Checkbox Hackは状態の可視性が著しく下がる
- アニメーションが複雑:Framer Motionなどのアニメーションライブラリと組み合わせる場合、CSSとJSが同じ状態を二重管理する問題が顕在化する
- チームがCSSに慣れていない:
:has()セレクタの解読コストがuseStateの解読コストを上回ることがある
⑧ 思想の衝突
最近のフロントエンド文化は、「UIの制御はJSフレームワークで一元管理する」方向に進んでいる。
Checkbox Hack はその逆だった。
「JSが管理しなければいけない」という前提を、CSSとHTML仕様で書き換えようとした。
その選択は「正しい」わけではない。【ある価値観のもとで「一貫している」だけだ。】
「インタラクションの完全性(Escキー、ARIA、アニメーション)を優先する」という価値観のもとでは、useDrawer + React stateのほうが一貫している。
「Hydrationギャップの排除とブラウザネイティブの制御を優先する」という価値観のもとでは、Checkbox Hackのほうが一貫している。
ClaudeMixは前者を選んだ。ただし、今は。
ランディングページがリッチになり、Framer Motionのような重いJSが入り、Hydrationギャップが長くなってきたとき、その判断は再び問い直される。
設計とは、何を便利にするかではない。
どの価値観を、どの順番で優先するかだ。
⑨ あなたはどちらを選ぶか
ナビゲーションの制御権は、ReactかブラウザのCSSか。
Hydrationギャップの排除と、Escキー・スクロールクローズの完全性。どちらを先に置くか。
Partial-JS(開閉はCSS、補完はJS)は、純粋な選択より複雑か、それとも現実的な妥協か。
「今は採用しない」という判断も、設計判断として記録する価値があるか。
この記事はClaudeMixで動いている。設計判断を二択で問い続けるプロダクトだ。あなたが今読んだ問いも、そこから来ている。
