FAQやヘルプページでよく使うアコーディオンUIは、「一度開いたセクションをページリロード後も開いた状態にしたい」というニーズがあります。localStorageを使えばブラウザを閉じても状態を保持できますが、複数のアコーディオンをまとめて管理したり、有効期限を設けたりする設計まで考えると実装の奥行きは深くなります。
この記事ではアクセシブルなアコーディオンの基本実装から、localStorageによる状態永続化、複数グループの管理、sessionStorageとの使い分け、エラーハンドリングまで体系的に解説します。
アクセシブルなアコーディオンの基本実装
まず、WAI-ARIAに対応したアコーディオンUIを作ります。aria-expanded と aria-controls を使うことでスクリーンリーダーが開閉状態を読み上げられます。
<div class="accordion" id="faq-accordion">
<div class="accordion-item">
<button
class="accordion-trigger"
aria-expanded="false"
aria-controls="faq-1"
id="faq-trigger-1"
>
よくある質問 1
<span class="accordion-icon" aria-hidden="true">▼</span>
</button>
<div
class="accordion-panel"
id="faq-1"
role="region"
aria-labelledby="faq-trigger-1"
hidden
>
<div class="accordion-body">
ここに回答が入ります。
</div>
</div>
</div>
<div class="accordion-item">
<button
class="accordion-trigger"
aria-expanded="false"
aria-controls="faq-2"
id="faq-trigger-2"
>
よくある質問 2
<span class="accordion-icon" aria-hidden="true">▼</span>
</button>
<div
class="accordion-panel"
id="faq-2"
role="region"
aria-labelledby="faq-trigger-2"
hidden
>
<div class="accordion-body">
ここに回答が入ります。
</div>
</div>
</div>
</div>
.accordion-item {
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 8px;
overflow: hidden;
}
.accordion-trigger {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
background: #f8fafc;
border: none;
cursor: pointer;
font-size: 1rem;
text-align: left;
}
.accordion-trigger:hover { background: #f1f5f9; }
/* aria-expanded="true" のときアイコンを回転 */
.accordion-trigger[aria-expanded="true"] .accordion-icon {
transform: rotate(180deg);
}
.accordion-icon {
transition: transform 0.2s ease;
font-size: 0.75rem;
}
/* パネルのアニメーション */
.accordion-panel {
/* hidden 属性が付いているときは非表示 */
}
.accordion-panel:not([hidden]) {
animation: slideDown 0.2s ease;
}
.accordion-body { padding: 16px; }
@keyframes slideDown {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.accordion-icon { transition: none; }
.accordion-panel:not([hidden]) { animation: none; }
}
function toggleAccordion(trigger) {
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
const panelId = trigger.getAttribute('aria-controls');
const panel = document.getElementById(panelId);
// 状態を反転
trigger.setAttribute('aria-expanded', !isExpanded);
panel.hidden = isExpanded;
}
// イベント委譲でアコーディオン全体を1つのリスナーで管理
document.querySelectorAll('.accordion').forEach(accordion => {
accordion.addEventListener('click', (e) => {
const trigger = e.target.closest('.accordion-trigger');
if (trigger) toggleAccordion(trigger);
});
});
aria-expanded="true/false":トリガーボタンに開閉状態を示すaria-controls:トリガーが制御するパネルのIDrole="region"+aria-labelledby:パネルに意味論的なラベルを付ける- 非表示パネルは
hidden属性(スクリーンリーダーに読まれない)
localStorage で開閉状態を保存する
アコーディオンの開閉状態をJSON形式でまとめてlocalStorageに保存します。1セクションにつき1つのキーを使う方法より、JSON一括管理の方が効率的です。
const STORAGE_KEY = 'accordion-state-faq';
/** 現在の開閉状態をオブジェクトで収集 */
function collectState(accordion) {
const state = {};
accordion.querySelectorAll('.accordion-trigger').forEach(trigger => {
const panelId = trigger.getAttribute('aria-controls');
state[panelId] = trigger.getAttribute('aria-expanded') === 'true';
});
return state;
}
/** 状態を localStorage に保存 */
function saveState(accordion) {
try {
const state = collectState(accordion);
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
// プライベートモードや容量超過でも動作を止めない
console.warn('localStorage への保存に失敗しました:', e.message);
}
}
/** localStorage から状態を復元 */
function restoreState(accordion) {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const state = JSON.parse(raw);
accordion.querySelectorAll('.accordion-trigger').forEach(trigger => {
const panelId = trigger.getAttribute('aria-controls');
const panel = document.getElementById(panelId);
const isOpen = state[panelId] === true;
trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
panel.hidden = !isOpen;
});
} catch (e) {
console.warn('アコーディオン状態の復元に失敗しました:', e.message);
}
}
document.querySelectorAll('.accordion').forEach(accordion => {
// ページ読み込み時に状態を復元
restoreState(accordion);
// クリックで開閉 + 状態保存
accordion.addEventListener('click', (e) => {
const trigger = e.target.closest('.accordion-trigger');
if (!trigger) return;
toggleAccordion(trigger);
saveState(accordion); // 変更のたびに保存
});
});
- 1つのキーで全セクションの状態を管理できる(localStorage のキー汚染を防ぐ)
- アコーディオンのセクション数が変わっても対応しやすい
JSON.stringify/JSON.parseを使うため型が保持される
localStorageの基本的な使い方はlocalStorage完全ガイドも参照してください。
複数アコーディオングループを独立管理する
ページ内にFAQ・仕様詳細・料金など複数のアコーディオンがある場合、グループごとに独立したキーで保存します。
<!-- グループごとに data-accordion-key を設定 --> <div class="accordion" id="faq-section" data-accordion-key="faq"> <!-- ... アコーディオンアイテム ... --> </div> <div class="accordion" id="spec-section" data-accordion-key="spec"> <!-- ... アコーディオンアイテム ... --> </div>
function getStorageKey(accordion) {
// data-accordion-key 属性があればそれを使い、なければIDから生成
const key = accordion.dataset.accordionKey ?? accordion.id ?? 'accordion';
return `accordion-state-${key}`;
}
document.querySelectorAll('.accordion').forEach(accordion => {
restoreState(accordion);
accordion.addEventListener('click', (e) => {
const trigger = e.target.closest('.accordion-trigger');
if (!trigger) return;
toggleAccordion(trigger);
// そのグループのキーで保存
try {
const state = collectState(accordion);
localStorage.setItem(getStorageKey(accordion), JSON.stringify(state));
} catch (e) {
console.warn('保存失敗:', e.message);
}
});
});
// 汎用的な復元関数(キーを受け取るように修正)
function restoreState(accordion) {
try {
const raw = localStorage.getItem(getStorageKey(accordion));
if (!raw) return;
const state = JSON.parse(raw);
accordion.querySelectorAll('.accordion-trigger').forEach(trigger => {
const panelId = trigger.getAttribute('aria-controls');
const panel = document.getElementById(panelId);
const isOpen = state[panelId] === true;
trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
panel.hidden = !isOpen;
});
} catch (e) {
console.warn('復元失敗:', e.message);
}
}
localStorage と sessionStorage の使い分け
アコーディオンの状態をどちらに保存するかは、「どのくらいの期間・範囲で状態を保持したいか」によって決まります。
| 観点 | localStorage | sessionStorage |
|---|---|---|
| 保持期間 | 明示的に削除するまで永続 | タブを閉じると消える |
| スコープ | 同じオリジン全体 | そのタブのみ |
| 容量 | 約 5MB | 約 5MB |
| アコーディオン向き | 長期FAQページ・設定系UI | 記事内・一時的な操作フロー |
// localStorage → sessionStorage に変えるだけで同じAPIが使える localStorage.setItem(key, value); // 永続保存 sessionStorage.setItem(key, value); // タブを閉じると消える
有効期限付きで状態を保存する
「7日間だけ状態を保持する」など有効期限を設けたい場合、localStorage はネイティブでは有効期限をサポートしていないため、保存時にタイムスタンプを一緒に記録します。
/**
* 有効期限付きで localStorage に保存するユーティリティ
*/
const timedStorage = {
set(key, value, ttlDays = 7) {
const item = {
value,
expiresAt: Date.now() + ttlDays * 24 * 60 * 60 * 1000,
};
try {
localStorage.setItem(key, JSON.stringify(item));
} catch (e) {
console.warn('保存失敗:', e.message);
}
},
get(key) {
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const item = JSON.parse(raw);
// 期限切れなら削除して null を返す
if (item.expiresAt && Date.now() > item.expiresAt) {
localStorage.removeItem(key);
return null;
}
return item.value;
} catch {
return null;
}
},
};
// 使用例:アコーディオン状態を7日間保存
function saveStateWithTTL(accordion) {
const state = collectState(accordion);
timedStorage.set(getStorageKey(accordion), state, 7);
}
function restoreStateWithTTL(accordion) {
const state = timedStorage.get(getStorageKey(accordion));
if (!state) return;
accordion.querySelectorAll('.accordion-trigger').forEach(trigger => {
const panelId = trigger.getAttribute('aria-controls');
const panel = document.getElementById(panelId);
const isOpen = state[panelId] === true;
trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
panel.hidden = !isOpen;
});
}
localStorage のエラーハンドリング
localStorageはプライベートブラウジングモードや容量超過時に例外を投げます。アコーディオンの状態保存はUXの補助機能なので、エラーが起きてもアコーディオン本体の動作を止めないことが重要です。
/**
* localStorage を安全に使うラッパー
* 書き込み・読み込みに失敗してもアプリが止まらない
*/
const safeStorage = {
set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) {
if (e.name === 'QuotaExceededError') {
// 容量超過時:古いアコーディオン状態キーを削除して再試行
this._cleanup();
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false;
}
}
return false; // プライベートモードなど
}
},
get(key, fallback = null) {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
},
remove(key) {
try { localStorage.removeItem(key); } catch { /* ignore */ }
},
/** accordion-state- で始まる古いキーを削除 */
_cleanup() {
const keysToDelete = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k?.startsWith('accordion-state-')) keysToDelete.push(k);
}
keysToDelete.forEach(k => localStorage.removeItem(k));
},
};
- プライベートブラウジング(Safari):Safariのプライベートモードでは localStorage への書き込みが
QuotaExceededErrorを投げます - 容量超過:ドメインごとに約 5MB の上限があります。アコーディオン状態だけなら数KB程度なので通常は問題ありません
- Cookie無効化:一部のブラウザ設定で localStorage が無効になる場合があります
いずれの場合も try/catch で囲んでフォールバックさせるのが原則です。
完成形:AccordionStateManager クラス
これまでの実装をひとまとめにした完成形のクラスです。
class AccordionStateManager {
#accordion;
#storageKey;
#ttlDays;
constructor(accordion, { ttlDays = 0 } = {}) {
this.#accordion = accordion;
this.#ttlDays = ttlDays; // 0 = 無期限
const key = accordion.dataset.accordionKey ?? accordion.id ?? 'default';
this.#storageKey = `accordion-state-${key}`;
this.#restore();
this.#bindEvents();
}
#bindEvents() {
this.#accordion.addEventListener('click', (e) => {
const trigger = e.target.closest('.accordion-trigger');
if (!trigger) return;
this.#toggle(trigger);
this.#save();
});
}
#toggle(trigger) {
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
const panel = document.getElementById(trigger.getAttribute('aria-controls'));
trigger.setAttribute('aria-expanded', !isExpanded ? 'true' : 'false');
panel.hidden = isExpanded;
}
#collectState() {
const state = {};
this.#accordion.querySelectorAll('.accordion-trigger').forEach(t => {
state[t.getAttribute('aria-controls')] =
t.getAttribute('aria-expanded') === 'true';
});
return state;
}
#save() {
const data = this.#ttlDays > 0
? { value: this.#collectState(), expiresAt: Date.now() + this.#ttlDays * 86400000 }
: this.#collectState();
try {
localStorage.setItem(this.#storageKey, JSON.stringify(data));
} catch (e) {
console.warn('AccordionStateManager: 保存失敗', e.message);
}
}
#restore() {
try {
const raw = localStorage.getItem(this.#storageKey);
if (!raw) return;
const parsed = JSON.parse(raw);
// 有効期限チェック
const state = parsed?.expiresAt
? (Date.now() > parsed.expiresAt ? null : parsed.value)
: parsed;
if (!state) { localStorage.removeItem(this.#storageKey); return; }
this.#accordion.querySelectorAll('.accordion-trigger').forEach(t => {
const panelId = t.getAttribute('aria-controls');
const panel = document.getElementById(panelId);
if (!panel) return;
const isOpen = state[panelId] === true;
t.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
panel.hidden = !isOpen;
});
} catch (e) {
console.warn('AccordionStateManager: 復元失敗', e.message);
}
}
/** 保存された状態をリセットする */
reset() {
localStorage.removeItem(this.#storageKey);
this.#accordion.querySelectorAll('.accordion-trigger').forEach(t => {
t.setAttribute('aria-expanded', 'false');
const panel = document.getElementById(t.getAttribute('aria-controls'));
if (panel) panel.hidden = true;
});
}
}
// 使用例:ページ内のすべてのアコーディオンを管理
document.querySelectorAll('.accordion').forEach(accordion => {
new AccordionStateManager(accordion, { ttlDays: 7 }); // 7日間保持
});
よくある質問(FAQ)
DOMContentLoaded を待たずに <script> タグでインラインで状態を即時反映する方法があります。または、CSSでアコーディオンの初期状態を「非表示」にしておき、JavaScriptが状態を復元した後に accordion.classList.add("ready") で可視化するパターンが有効です。localStorage.setItem() が QuotaExceededError を投げます。本記事の AccordionStateManager は try/catch でエラーを捕捉してアコーディオンの動作は継続させます。状態の永続化だけが無効になります。ttlDays オプション)を使うか、コンテンツのバージョンをキーに含める方法があります。例えばストレージキーを accordion-state-faq-v2 のようにバージョン付きにすれば、バージョンアップ時に自動的に古いデータを無視できます。window.addEventListener("storage", handler) を使います。同一オリジンの別タブでlocalStorageが変更されると storage イベントが発火します。イベントの key と newValue を使って同期できます。JSON.stringify({ open: "faq-2" }) のようにシンプルな形式で保存できます。まとめ
アコーディオン状態の永続化を用途別に整理します。
| 要件 | 実装ポイント |
|---|---|
| 基本的な開閉状態の保存 | JSON一括保存(localStorage.setItem(key, JSON.stringify(state))) |
| 複数グループの独立管理 | data-accordion-key 属性でキーを分離 |
| タブを閉じたら消す | sessionStorage を使う |
| 有効期限を設ける | expiresAt タイムスタンプを一緒に保存 |
| エラー耐性 | 全保存・復元を try/catch で囲む |
| アクセシビリティ | aria-expanded + hidden 属性で状態を同期 |
localStorageの詳しい使い方はlocalStorage完全ガイドを、フォーム入力内容の保存についてはフォーム入力をlocalStorageに保存して自動復元する方法もあわせて参照してください。