本を閉じるだけなら、簡単な仕事に見えます。
本を隠す。後ろの店内を、また操作できるようにする。片づけ完了です。
ただ、キーボードで操作している人には、もう一つ返すものがあります。本を開く直前に、操作していた場所です。 画面を片づけただけでは、読者の帰り道まで用意したことになりません。
個人開発のAfter 6 Streetでは、店内のボタンから、本を開くような読書画面へ移動できます。今回は、この画面を閉じたときのフォーカス復帰を取り出します。
「閉じたら戻す」は親切そうです。でも、閉じる処理を初期化にも使うと、少し話が変わります。まだ何も開いていない人まで、カウンターへ連れ戻しかねません。
この記事で扱うのは、本当に開いていた場合だけ操作元へ戻し、開いていなかった場合は現在のフォーカスに触らないという判断です。ライブラリなしで試せる、一枚のHTMLも載せます。
隠したのは本。見失うのは操作場所
フォーカスは、キーボード入力を受け取る場所です。本文をマウスで選択している範囲とは別で、通常は document.activeElement で現在の要素を参照できます。MDNの説明
たとえば、Tabで「棚Bの本を開く」まで進み、Enterで本を開くとします。読書画面を閉じたあとは、棚Bのボタンへ戻れば、その続きから操作できます。
ところが「本が消えれば終わり」にすると、その戻り先が抜けます。逆に、毎回決まった「カウンターの本」へ戻せばよいかというと、それも少し雑です。棚Bまで来た人を、店員の都合で入口付近へ案内し直すことになります。
WAI-ARIA APGのモーダルダイアログのパターンでも、閉じたときは原則として開いた操作元へ戻し、操作元が消えた場合などは、作業の流れに合う戻り先を選びます。Dialog (Modal) Pattern
そこで、開くときに操作元を覚え、閉じるときに返します。「どの棚から来たか」まで含めた返却です。カウンターに返却口があるからといって、すべてのお客さんをそこへ並ばせる必要はありません。
ところが、開いていない本も閉じる
ここからが、今回いちばん残したかった条件です。
次のような閉じる処理を考えます。これは問題を説明するための仮のコードです。
function closeBook() {
overlay.hidden = true;
room.inert = false;
openButton.focus({ preventScroll: true });
}
この関数は、表示を「本が閉じている状態」にそろえるためにも使えます。しかし、初期表示で呼んでも、最後の focus() は実行されます。入力欄などにフォーカスがあれば、閉じた本がなくても、決まったボタンへ移してしまいます。
「閉じた状態にする」と「開いていた本を閉じる」は、最終的な見た目が同じでも、起きたことが違います。
Streetの実装も、URLに応じて表示を決める処理から closeBook() を呼びます。閉じるボタンだけの専属係ではありません。そのため、変更する前の表示状態を見てから、フォーカスを返すか決めています。
次は、参照した src/app.js の関係する行だけを抜き出したものです。間にある履歴・会話などの処理は省略しています。
const focusTarget = !$('.bk-overlay').hidden
? (state.focus && root.contains(state.focus)
? state.focus
: $('.bk-open-volume'))
: null;
// 履歴や会話に関する処理は省略
$('.bk-overlay').hidden = true;
$('.bk-room').inert = false;
// 関連する画面状態の更新は省略
focusTarget?.focus({ preventScroll: true });
開いていなかった場合、戻り先は null です。フォーカス復帰もしません。
ここで先に hidden = true にしてから「開いていたか」を調べると、毎回「閉じています」という返事になります。片づけたあとに「さっき散らかっていましたか」と聞いても、現場はもう残っていません。
帰り先が残っているか、帰れる状態か
実装では、開くときの document.activeElement を state.focus に記録しています。会話UIの中にフォーカスがある場合は、別の操作先へ補正する処理もあります。ここでは、通常のボタンから開く経路へ絞ります。
閉じるときの判断は、次の三つです。
| 閉じる前の状態 | 記録した操作元 | フォーカスの扱い |
|---|---|---|
| 開いていない | あってもなくても | 今いる場所のまま |
| 開いている | 店内のDOMに残っている | 記録した操作元へ戻す |
| 開いている | 残っていない、または記録がない | カウンターの本へ戻す |
root.contains(returnTarget) は、要素が店内のDOMの配下に残っているかの確認です。「同じIDのボタンを作り直した」場合は、元の要素とは別物です。削除済みのボタンへの参照を握っていても、そこへ帰れるわけではありません。MDN:Node.contains()
順番も大事です。本を開いている間、店内側には inert を付けています。閉じたら店内側の inert を解除し、そのあとに操作元へ focus() します。
inert は、見た目を薄暗くするCSSではありません。その領域へのフォーカスや操作を無効にするものです。先にフォーカスだけ返そうとしても、戻り先が操作できる状態にはなっていません。MDN:HTMLElement.inert
preventScroll: true は、このフォーカス移動に伴うスクロールを抑える指定です。スクロール位置を保存・復元する処理とは別です。MDN:HTMLElement.focus()
なお、contains() だけでは、非表示・無効化された要素まで「戻れる」と保証できません。この縮小例では、操作元は表示中の有効なボタン、代替先は常に残るボタン、という条件に絞っています。表示条件が変わる画面なら、戻り先の有効性も設計に含めます。
小さな実装へ取り出す
以下はStreetの全実装ではなく、フォーカスを預かって返す部分を整理した記事用のコードです。
実際の実装は開く直前の activeElement を保存します。この例では、クリックされたボタンを open(button) に渡します。ブラウザーや操作方法によってクリック時のフォーカスの扱いが異なるため、「どのボタンで開いたか」を直接渡す形にしています。
function createReader({ root, room, overlay, firstFocus, fallback }) {
let returnTarget = null;
function open(opener) {
if (!overlay.hidden) return;
returnTarget = opener;
overlay.hidden = false;
room.inert = true;
firstFocus.focus({ preventScroll: true });
}
function close() {
const wasOpen = !overlay.hidden;
const target = wasOpen
? (returnTarget && root.contains(returnTarget) ? returnTarget : fallback)
: null;
overlay.hidden = true;
room.inert = false;
returnTarget = null;
target?.focus({ preventScroll: true });
}
return { open, close };
}
close() は、先に開いていたかを記録します。表示と inert を閉じた状態にそろえたあと、必要な場合だけ戻り先へフォーカスします。保存していた参照も、その回の返却が終わったら手放します。
open() の先頭では、すでに開いている場合を除外しました。この縮小例では、本の中で再度開く処理が呼ばれても、「元いた場所」を本の中のボタンで上書きしないためです。
そのまま保存して試せるHTML全文
以下を demo.html として保存し、ブラウザーで開いてください。外部ファイルやパッケージは不要です。
棚A・棚Bから開いて閉じる操作と、「開いたボタンを撤去して閉じる」操作を試せます。Escape、Tab、Shift+Tabへの対応も含めています。Tabの処理は、このデモにあるボタンだけを対象としたものです。
<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>本を閉じたら、フォーカスはどこへ?</title>
<style>
*{box-sizing:border-box}body{margin:0;font:16px/1.9 system-ui,"Hiragino Kaku Gothic ProN",Meiryo,sans-serif;color:#243e39;background:#f4f7f4}main{max-width:780px;margin:auto;padding:30px 24px}h1{font-size:25px;line-height:1.6}h2{font-size:22px}label{display:block}input{width:100%;max-width:400px;margin:5px 0 15px;padding:10px;border:1px solid #a8bdb4;border-radius:6px;font:inherit}button{font:inherit;border:1px solid #8aaca0;border-radius:7px;padding:10px 15px;margin:5px 5px 5px 0;background:#fff;color:#234c40;cursor:pointer}button:hover{background:#e7f2eb}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:3px solid #d3732d;outline-offset:4px}[hidden]{display:none!important}.eyebrow{font-size:12px;color:#547b69;letter-spacing:.1em}.controls{padding:15px 0}.hint{font-size:14px;color:#597069}.state{font:13px/1.8 ui-monospace,monospace;padding:12px 15px;background:#e4eee8;border-radius:8px;overflow-wrap:anywhere}#overlay{position:fixed;inset:0;z-index:10;display:grid;place-items:center;padding:20px;background:#173a34aa}.paper{width:min(590px,100%);max-height:85vh;overflow:auto;background:#fffdf6;border-radius:12px;padding:25px;box-shadow:0 15px 60px #183c3244}code{background:#e7eee7;padding:2px 4px;border-radius:3px}a{color:#1c6f52}
</style>
<main id="shop">
<div id="room">
<p class="eyebrow">VANILLA HTML / CSS / JAVASCRIPT</p>
<h1>本を閉じたら、フォーカスはどこへ?</h1>
<label for="search">店内検索(デモなので検索は実行しません)</label>
<input id="search" placeholder="この入力欄も、勝手に連れ戻されたくない場所です">
<div class="controls">
<button id="opener-a" data-open>棚Aの本を開く</button>
<button id="opener-b" data-open>棚Bの本を開く</button>
<button id="fallback" data-open>カウンターの本を開く</button>
</div>
<button id="normalise-close">閉じている状態でclose()を呼ぶ</button>
<p class="hint">Tabでボタンへ移動し、Enterで本を開いてください。閉じると、開いたボタンへ戻ります。「開いたボタンを撤去して閉じる」では、カウンターの本へ戻ります。</p>
<p class="hint">初期化時にもclose()を呼びます。何も開いていないので、フォーカスは動かしません。</p>
<p id="focus-state" class="state" aria-live="polite"></p>
</div>
<section id="overlay" role="dialog" aria-modal="true" aria-labelledby="book-title" hidden>
<div class="paper">
<h2 id="book-title">閉じるための短い本</h2>
<p>この本を読み終えても、読書家になったかどうかは判定しません。今回は、開いた場所だけ覚えておきます。</p>
<p>閉じると、先ほど操作していたボタンにフォーカスが戻ります。戻り先が撤去されていたら、カウンターの本が代わりの戻り先です。</p>
<button id="close-book">本を閉じる</button>
<button id="remove-opener">開いたボタンを撤去して閉じる</button>
<p class="hint">Escapeでも閉じられます。TabとShift+Tabは、この本の中を移動します。</p>
</div>
</section>
</main>
<script type="module">
function createReader({ root, room, overlay, firstFocus, fallback }) {
let returnTarget = null;
function open(opener) {
if (!overlay.hidden) return;
returnTarget = opener;
overlay.hidden = false;
room.inert = true;
firstFocus.focus({ preventScroll: true });
}
function close() {
const wasOpen = !overlay.hidden;
const target = wasOpen
? (returnTarget && root.contains(returnTarget) ? returnTarget : fallback)
: null;
overlay.hidden = true;
room.inert = false;
returnTarget = null;
target?.focus({ preventScroll: true });
}
return { open, close };
}
const root = document.querySelector('#shop');
const room = document.querySelector('#room');
const overlay = document.querySelector('#overlay');
const closeButton = document.querySelector('#close-book');
const fallback = document.querySelector('#fallback');
const removeButton = document.querySelector('#remove-opener');
const reader = createReader({ root, room, overlay, firstFocus: closeButton, fallback });
let opener = null;
for (const button of root.querySelectorAll('[data-open]')) {
button.addEventListener('click', () => {
opener = button;
removeButton.disabled = button === fallback;
reader.open(button);
});
}
closeButton.addEventListener('click', reader.close);
document.querySelector('#normalise-close').addEventListener('click', reader.close);
removeButton.addEventListener('click', () => {
if (opener && opener !== fallback) opener.remove();
reader.close();
});
overlay.addEventListener('keydown', event => {
if (event.key === 'Escape') {
event.preventDefault();
reader.close();
}
if (event.key !== 'Tab') return;
const buttons = [...overlay.querySelectorAll('button:not([disabled])')];
const first = buttons[0], last = buttons.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault(); last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault(); first.focus();
}
});
function showState() {
const focused = document.activeElement;
document.querySelector('#focus-state').textContent =
`focus: ${focused.id || focused.tagName.toLowerCase()} / hidden: ${overlay.hidden} / inert: ${room.inert}`;
}
document.addEventListener('focusin', showState);
reader.close();
showState();
</script>
</html>
ここでは既存の独自オーバーレイから判断を取り出しました。これから単純なモーダルを新しく作るなら、標準の <dialog> と showModal() も先に検討したいところです。モーダルとして開くと、同じ文書の背後を操作できない状態にするなど、ブラウザーが受け持つ部分があります。MDN:showModal()
「閉じられた」だけで合格にしない
記事用の縮小例は、Chrome 155/Linuxの実DOMで、次の結果を確認しました。画面が消えたかに加えて、document.activeElement が期待した要素かを調べています。
| 操作 | 確認結果 |
|---|---|
閉じた状態で検索欄へフォーカスし、close()
|
検索欄のまま |
| 棚Aから開いて閉じる | 棚Aへ戻った |
| 棚Bから開いて閉じる | 棚Bへ戻った |
| 開くときのボタンを削除して閉じる | カウンターの本へ戻った |
一度閉じ、検索欄へ移ってから再度 close()
|
検索欄のまま |
開いている間に再度 open() してから閉じる |
最初の操作元へ戻った |
開いたときは本の内側のボタンへ移ること、閉じたあとは店内の inert が解除されることも確認しました。デモでは、TabとEnterで開き、Shift+Tabの折り返し、Escapeで閉じて操作元へ戻る経路も操作しました。
Streetにある既存の tests-book-focus.mjs も、参照した実ソースに対して実行しました。こちらはNode.js上のスタブによる確認で、初期表示やすでに閉じた状態ではフォーカスを奪わず、開いていた場合は操作元または代替先へ戻すことを調べています。実ブラウザーの確認とは区別しています。
今回確かめたのは、この復帰判断と記事用デモです。Street全体のアクセシビリティ監査、スクリーンリーダーでの読み上げ、Safari・Firefox・実機での動作確認までは行っていません。role="dialog" や aria-modal="true" を付けただけで、キーボード操作が実装されるわけでもありません。
参照した実装と一次資料
実装の参照日は2026年11月3日(日本時間)です。Sites上の直近の公開完了記録はv86、対応コミットは c4a3802b523ca79bdc47eb94af77b5a7d03ce83b でした。最新の保存版はv88ですが、保存版数と公開完了は区別しています。今回はv86の src/app.js、src/store.html、tests-book-focus.mjs を読み取りました。記事のためのサイト改修や公開はしていません。
この記事のソース調査、縮小例の実装・検証、構成・文章作成に生成AIを利用しています。
後片づけの対象を、増やしすぎない
本を隠す。後ろの店内を、また操作できるようにする。
そこに、開く前の操作場所へ戻す仕事も加わりました。ただし、その仕事があるのは、本を開いていたときだけです。初期化のたびに、今いる場所から呼び戻す必要はありません。
本が閉じているなら、片づけは済んでいます。
お客さんまで定位置へ片づけなくていい。