【JavaScript】ローカルストレージでアコーディオンの状態を保存する方法|WAI-ARIA・複数グループ管理・有効期限付き保存まで解説

FAQやヘルプページでよく使うアコーディオンUIは、「一度開いたセクションをページリロード後も開いた状態にしたい」というニーズがあります。localStorageを使えばブラウザを閉じても状態を保持できますが、複数のアコーディオンをまとめて管理したり、有効期限を設けたりする設計まで考えると実装の奥行きは深くなります。

この記事ではアクセシブルなアコーディオンの基本実装から、localStorageによる状態永続化、複数グループの管理、sessionStorageとの使い分け、エラーハンドリングまで体系的に解説します。

スポンサーリンク

アクセシブルなアコーディオンの基本実装

まず、WAI-ARIAに対応したアコーディオンUIを作ります。aria-expandedaria-controls を使うことでスクリーンリーダーが開閉状態を読み上げられます。

HTML(WAI-ARIA対応)
<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>
CSS(スムーズアニメーション)
.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; }
}
JavaScript(基本の開閉ロジック)
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);
  });
});
WAI-ARIAの要件:

  • aria-expanded="true/false":トリガーボタンに開閉状態を示す
  • aria-controls:トリガーが制御するパネルのID
  • role="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);  // 変更のたびに保存
  });
});
JSON一括保存のメリット:

  • 1つのキーで全セクションの状態を管理できる(localStorage のキー汚染を防ぐ)
  • アコーディオンのセクション数が変わっても対応しやすい
  • JSON.stringify / JSON.parse を使うため型が保持される

localStorageの基本的な使い方はlocalStorage完全ガイドも参照してください。

複数アコーディオングループを独立管理する

ページ内にFAQ・仕様詳細・料金など複数のアコーディオンがある場合、グループごとに独立したキーで保存します。

HTML(グループIDを data 属性で指定)
<!-- グループごとに 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 記事内・一時的な操作フロー
sessionStorage で保存する場合(コード差分のみ)
// localStorage → sessionStorage に変えるだけで同じAPIが使える
localStorage.setItem(key, value);   // 永続保存
sessionStorage.setItem(key, value); // タブを閉じると消える
localStorageとsessionStorageの詳しい違いはlocalStorage と sessionStorage の違いと使い分けで解説しています。

有効期限付きで状態を保存する

「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 ラッパー
/**
 * 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));
  },
};
localStorage が使えないケース:

  • プライベートブラウジング(Safari):Safariのプライベートモードでは localStorage への書き込みが QuotaExceededError を投げます
  • 容量超過:ドメインごとに約 5MB の上限があります。アコーディオン状態だけなら数KB程度なので通常は問題ありません
  • Cookie無効化:一部のブラウザ設定で localStorage が無効になる場合があります

いずれの場合も try/catch で囲んでフォールバックさせるのが原則です。

完成形:AccordionStateManager クラス

これまでの実装をひとまとめにした完成形のクラスです。

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)

Qページをリロードするとアコーディオンが一瞬閉じてから開く(チラつく)のを防ぐには?
ADOMContentLoaded を待たずに <script> タグでインラインで状態を即時反映する方法があります。または、CSSでアコーディオンの初期状態を「非表示」にしておき、JavaScriptが状態を復元した後に accordion.classList.add("ready") で可視化するパターンが有効です。
Qプライベートモードで localStorageへの書き込みがエラーになります。
ASafari のプライベートモードでは localStorage.setItem()QuotaExceededError を投げます。本記事の AccordionStateManagertry/catch でエラーを捕捉してアコーディオンの動作は継続させます。状態の永続化だけが無効になります。
Qアコーディオンの内容が更新されたとき、古い状態データをクリアするには?
A有効期限付き保存(ttlDays オプション)を使うか、コンテンツのバージョンをキーに含める方法があります。例えばストレージキーを accordion-state-faq-v2 のようにバージョン付きにすれば、バージョンアップ時に自動的に古いデータを無視できます。
Q複数タブで同期して状態を反映するには?
Awindow.addEventListener("storage", handler) を使います。同一オリジンの別タブでlocalStorageが変更されると storage イベントが発火します。イベントの keynewValue を使って同期できます。
Qアコーディオンを1つだけ開く(排他制御)にしつつ状態保存したいときは?
Aトリガーをクリックしたとき、まず他のすべてのパネルを閉じてから対象パネルを開くようにします。状態は「最後に開いたパネルのID」1つだけを保存すれば十分です。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に保存して自動復元する方法もあわせて参照してください。