HTMLでアコーディオンを作る方法|シンプルなサンプルとカスタマイズ例

html

ホームページやブログで

「クリックすると内容が開いたり閉じたりするメニュー」

を見たことはありませんか?

これをアコーディオンメニューと呼びます。FAQ(よくある質問)の開閉や、長い説明をコンパクトにまとめるときにとても便利です。

この記事では、

  • HTMLとCSSだけで作るシンプルなアコーディオン
  • JavaScriptを使ってもっと自由に動かす例
  • 実用的なカスタマイズテクニック

を初心者向けにわかりやすく解説します。

これを読めば、あなたのサイトにもすぐにアコーディオンが設置できますよ。

スポンサーリンク

アコーディオンとは?

アコーディオンの特徴

アコーディオンは、限られたスペースで多くの情報を効率的に表示できるUI(ユーザーインターフェース)コンポーネントです。

特徴説明効果
省スペース必要な情報のみ表示ページの整理整頓
ユーザビリティクリックで詳細表示情報の段階的開示
視認性向上重要な項目を強調コンテンツの優先順位付け

よく使われる場面

  • FAQ:よくある質問と回答
  • 製品情報:スペックや詳細の表示
  • ナビゲーション:サブメニューの表示
  • フォーム:複雑な入力項目の整理
  • ダッシュボード:情報パネルの管理

HTMLとCSSだけで簡単アコーディオン(detailsタグ)

基本的な使い方

HTML5で新しく使える<details><summary>を使う方法が最もシンプルです:

<details>
  <summary>質問1:送料はいくらですか?</summary>
  <p>全国一律500円です。5,000円以上のお買い上げで送料無料になります。</p>
</details>

<details>
  <summary>質問2:返品はできますか?</summary>
  <p>商品到着後7日以内であれば返品可能です。未使用品に限ります。</p>
</details>

<details>
  <summary>質問3:配送にはどのくらいかかりますか?</summary>
  <p>通常2-3営業日でお届けします。お急ぎの場合は翌日配送(別途料金)も承ります。</p>
</details>

detailsタグの属性

属性説明
open初期状態で開いておく<details open>
nameグループ化(一つだけ開く)<details name="faq">
<!-- 初期状態で開いているアコーディオン -->
<details open>
  <summary>重要なお知らせ</summary>
  <p>サイトメンテナンスのお知らせです。</p>
</details>

<!-- 同じnameを持つ要素は一つだけ開く(新機能) -->
<details name="exclusive-group">
  <summary>オプション1</summary>
  <p>オプション1の詳細</p>
</details>

<details name="exclusive-group">
  <summary>オプション2</summary>
  <p>オプション2の詳細</p>
</details>

メリット・デメリット

メリット

  • JavaScript不要:HTMLだけで動作
  • アクセシビリティ:スクリーンリーダー対応
  • 軽量:追加のライブラリ不要
  • セマンティック:意味的に正しいマークアップ

デメリット

  • デザイン制限:カスタマイズがやや限定的
  • ブラウザサポート:IE11以前では動作しない
  • アニメーション制限:標準のアニメーションのみ

CSSでdetailsタグをカスタマイズ

基本的なスタイリング

<style>
details {
  width: 90%;
  max-width: 600px;
  margin: 15px auto;
  padding: 0;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  background: #ffffff;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  overflow: hidden;
}

summary {
  padding: 15px 20px;
  font-weight: 600;
  cursor: pointer;
  background: #f8f9fa;
  border-bottom: 1px solid #e0e0e0;
  transition: background-color 0.3s ease;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

summary:hover {
  background-color: #e9ecef;
}

details[open] summary {
  background-color: #007bff;
  color: white;
}

.details-content {
  padding: 20px;
  line-height: 1.6;
  color: #555;
}
</style>

<details>
  <summary>
    送料について
    <span class="arrow">▼</span>
  </summary>
  <div class="details-content">
    <p>全国一律500円です。5,000円以上のお買い上げで送料無料になります。</p>
    <ul>
      <li>通常配送:2-3営業日</li>
      <li>お急ぎ便:翌日配送(+300円)</li>
      <li>時間指定:午前/午後指定可能</li>
    </ul>
  </div>
</details>

三角マーカーのカスタマイズ

/* デフォルトの三角を非表示 */
summary {
  list-style: none;
}

summary::-webkit-details-marker {
  display: none;
}

/* カスタム矢印アイコン */
summary::after {
  content: '▼';
  transition: transform 0.3s ease;
  margin-left: auto;
}

details[open] summary::after {
  transform: rotate(180deg);
}

/* より高度な矢印スタイル */
.custom-arrow {
  position: relative;
}

.custom-arrow::after {
  content: '';
  width: 10px;
  height: 10px;
  border-top: 2px solid #666;
  border-right: 2px solid #666;
  transform: rotate(135deg);
  transition: transform 0.3s ease;
}

details[open] .custom-arrow::after {
  transform: rotate(-45deg);
}

アニメーション効果

/* スムーズな開閉アニメーション */
details {
  overflow: hidden;
}

.details-content {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.3s ease-out;
}

details[open] .details-content {
  max-height: 500px; /* 十分な高さを指定 */
  transition: max-height 0.3s ease-in;
}

/* より滑らかなアニメーション */
@keyframes slideDown {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

details[open] .details-content {
  animation: slideDown 0.3s ease-out;
}

JavaScriptで高度なアコーディオン

基本的なJavaScriptアコーディオン

<style>
.accordion-container {
  max-width: 600px;
  margin: 20px auto;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

.accordion-item {
  border-bottom: 1px solid #e0e0e0;
}

.accordion-item:last-child {
  border-bottom: none;
}

.accordion-header {
  padding: 18px 20px;
  background: #f8f9fa;
  cursor: pointer;
  font-weight: 600;
  display: flex;
  justify-content: space-between;
  align-items: center;
  transition: all 0.3s ease;
  border: none;
  width: 100%;
  text-align: left;
}

.accordion-header:hover {
  background: #e9ecef;
}

.accordion-header.active {
  background: #007bff;
  color: white;
}

.accordion-content {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.3s ease;
  background: white;
}

.accordion-content.active {
  max-height: 300px;
}

.accordion-body {
  padding: 20px;
  line-height: 1.6;
  color: #555;
}

.accordion-icon {
  transition: transform 0.3s ease;
}

.accordion-header.active .accordion-icon {
  transform: rotate(180deg);
}
</style>

<div class="accordion-container">
  <div class="accordion-item">
    <button class="accordion-header">
      <span>支払い方法について</span>
      <span class="accordion-icon">▼</span>
    </button>
    <div class="accordion-content">
      <div class="accordion-body">
        <p>以下の支払い方法をご利用いただけます:</p>
        <ul>
          <li>クレジットカード(VISA、MasterCard、JCB)</li>
          <li>銀行振込</li>
          <li>コンビニ決済</li>
          <li>PayPal</li>
        </ul>
      </div>
    </div>
  </div>

  <div class="accordion-item">
    <button class="accordion-header">
      <span>返品・交換について</span>
      <span class="accordion-icon">▼</span>
    </button>
    <div class="accordion-content">
      <div class="accordion-body">
        <p>商品到着後7日以内であれば返品・交換が可能です。</p>
        <p><strong>条件:</strong></p>
        <ul>
          <li>未使用・未開封の商品</li>
          <li>商品タグが付いている</li>
          <li>返品送料はお客様負担</li>
        </ul>
      </div>
    </div>
  </div>

  <div class="accordion-item">
    <button class="accordion-header">
      <span>配送について</span>
      <span class="accordion-icon">▼</span>
    </button>
    <div class="accordion-content">
      <div class="accordion-body">
        <p>全国どこでも配送いたします。配送料は地域により異なります。</p>
        <table style="width: 100%; border-collapse: collapse; margin-top: 10px;">
          <tr>
            <th style="border: 1px solid #ddd; padding: 8px;">地域</th>
            <th style="border: 1px solid #ddd; padding: 8px;">配送料</th>
          </tr>
          <tr>
            <td style="border: 1px solid #ddd; padding: 8px;">関東・関西</td>
            <td style="border: 1px solid #ddd; padding: 8px;">500円</td>
          </tr>
          <tr>
            <td style="border: 1px solid #ddd; padding: 8px;">その他本州</td>
            <td style="border: 1px solid #ddd; padding: 8px;">700円</td>
          </tr>
          <tr>
            <td style="border: 1px solid #ddd; padding: 8px;">北海道・沖縄</td>
            <td style="border: 1px solid #ddd; padding: 8px;">1,000円</td>
          </tr>
        </table>
      </div>
    </div>
  </div>
</div>

<script>
class Accordion {
  constructor(container) {
    this.container = container;
    this.headers = container.querySelectorAll('.accordion-header');
    this.init();
  }

  init() {
    this.headers.forEach(header => {
      header.addEventListener('click', (e) => {
        this.toggle(e.currentTarget);
      });
    });
  }

  toggle(header) {
    const content = header.nextElementSibling;
    const isActive = header.classList.contains('active');

    // 他のアイテムを閉じる(オプション)
    this.closeAll();

    if (!isActive) {
      header.classList.add('active');
      content.classList.add('active');
      content.style.maxHeight = content.scrollHeight + 'px';
    }
  }

  closeAll() {
    this.headers.forEach(header => {
      header.classList.remove('active');
      header.nextElementSibling.classList.remove('active');
      header.nextElementSibling.style.maxHeight = '0';
    });
  }

  openAll() {
    this.headers.forEach(header => {
      header.classList.add('active');
      const content = header.nextElementSibling;
      content.classList.add('active');
      content.style.maxHeight = content.scrollHeight + 'px';
    });
  }
}

// 初期化
document.addEventListener('DOMContentLoaded', () => {
  const accordionContainer = document.querySelector('.accordion-container');
  new Accordion(accordionContainer);
});
</script>

複数同時開閉対応アコーディオン

<style>
.multi-accordion .accordion-header.active {
  background: #28a745;
}
</style>

<div class="accordion-container multi-accordion">
  <!-- アコーディオンアイテム(上記と同じHTML構造) -->
</div>

<script>
class MultiAccordion {
  constructor(container) {
    this.container = container;
    this.headers = container.querySelectorAll('.accordion-header');
    this.init();
  }

  init() {
    this.headers.forEach(header => {
      header.addEventListener('click', (e) => {
        this.toggle(e.currentTarget);
      });
    });
  }

  toggle(header) {
    const content = header.nextElementSibling;
    const isActive = header.classList.contains('active');

    if (isActive) {
      // 閉じる
      header.classList.remove('active');
      content.classList.remove('active');
      content.style.maxHeight = '0';
    } else {
      // 開く
      header.classList.add('active');
      content.classList.add('active');
      content.style.maxHeight = content.scrollHeight + 'px';
    }
  }
}
</script>

高度なカスタマイズとアニメーション

CSS アニメーションライブラリとの連携

<style>
@import url('https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css');

.accordion-content.active .accordion-body {
  animation: fadeInUp 0.5s ease;
}

/* カスタムアニメーション */
@keyframes accordionSlide {
  from {
    opacity: 0;
    transform: translateY(-20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.smooth-accordion .accordion-content.active .accordion-body {
  animation: accordionSlide 0.4s ease-out;
}
</style>

アイコンとビジュアル要素

<style>
.icon-accordion .accordion-header {
  display: flex;
  align-items: center;
  gap: 12px;
}

.accordion-icon-left {
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #007bff;
  color: white;
  border-radius: 50%;
  font-size: 14px;
}

.accordion-progress {
  height: 3px;
  background: #e9ecef;
  overflow: hidden;
}

.accordion-progress-bar {
  height: 100%;
  background: linear-gradient(90deg, #007bff, #0056b3);
  width: 0%;
  transition: width 0.3s ease;
}

.accordion-item.active .accordion-progress-bar {
  width: 100%;
}
</style>

<div class="accordion-item icon-accordion">
  <button class="accordion-header">
    <span class="accordion-icon-left">💳</span>
    <span style="flex: 1;">支払い方法</span>
    <span class="accordion-icon">▼</span>
  </button>
  <div class="accordion-progress">
    <div class="accordion-progress-bar"></div>
  </div>
  <div class="accordion-content">
    <div class="accordion-body">
      <!-- コンテンツ -->
    </div>
  </div>
</div>

アクセシビリティ対応

適切なARIA属性

<div class="accordion-item">
  <button 
    class="accordion-header"
    aria-expanded="false"
    aria-controls="accordion-content-1"
    id="accordion-header-1"
  >
    <span>アクセシブルなアコーディオン</span>
    <span class="accordion-icon" aria-hidden="true">▼</span>
  </button>
  <div 
    class="accordion-content"
    id="accordion-content-1"
    aria-labelledby="accordion-header-1"
    role="region"
  >
    <div class="accordion-body">
      <p>スクリーンリーダーに対応したアコーディオンです。</p>
    </div>
  </div>
</div>

キーボードナビゲーション

class AccessibleAccordion {
  constructor(container) {
    this.container = container;
    this.headers = container.querySelectorAll('.accordion-header');
    this.init();
  }

  init() {
    this.headers.forEach((header, index) => {
      header.addEventListener('click', (e) => this.toggle(e.currentTarget));
      header.addEventListener('keydown', (e) => this.handleKeydown(e, index));
    });
  }

  handleKeydown(e, index) {
    const key = e.key;
    let targetIndex = index;

    switch (key) {
      case 'ArrowDown':
        e.preventDefault();
        targetIndex = (index + 1) % this.headers.length;
        break;
      case 'ArrowUp':
        e.preventDefault();
        targetIndex = (index - 1 + this.headers.length) % this.headers.length;
        break;
      case 'Home':
        e.preventDefault();
        targetIndex = 0;
        break;
      case 'End':
        e.preventDefault();
        targetIndex = this.headers.length - 1;
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        this.toggle(this.headers[index]);
        return;
    }

    this.headers[targetIndex].focus();
  }

  toggle(header) {
    const content = header.nextElementSibling;
    const isExpanded = header.getAttribute('aria-expanded') === 'true';

    header.setAttribute('aria-expanded', !isExpanded);
    
    if (isExpanded) {
      header.classList.remove('active');
      content.classList.remove('active');
      content.style.maxHeight = '0';
    } else {
      header.classList.add('active');
      content.classList.add('active');
      content.style.maxHeight = content.scrollHeight + 'px';
    }
  }
}

実用的な活用例

FAQ セクション

<section class="faq-section">
  <h2>よくある質問</h2>
  <div class="faq-accordion">
    <details>
      <summary>注文のキャンセルはできますか?</summary>
      <div class="faq-answer">
        <p>発送前であればキャンセル可能です。マイページまたはお電話でご連絡ください。</p>
        <p><strong>連絡先:</strong> 0120-XXX-XXX(平日9:00-18:00)</p>
      </div>
    </details>

    <details>
      <summary>商品の在庫確認方法は?</summary>
      <div class="faq-answer">
        <p>商品ページで在庫状況をリアルタイムで確認できます。</p>
        <ul>
          <li>🟢 在庫あり:即日発送可能</li>
          <li>🟡 残りわずか:1-2個在庫</li>
          <li>🔴 在庫切れ:入荷待ち</li>
        </ul>
      </div>
    </details>
  </div>
</section>

製品仕様表示

<div class="product-specs">
  <h3>製品仕様</h3>
  <details class="spec-group">
    <summary>基本スペック</summary>
    <table class="spec-table">
      <tr><td>サイズ</td><td>W 120cm × D 60cm × H 75cm</td></tr>
      <tr><td>重量</td><td>15kg</td></tr>
      <tr><td>材質</td><td>天然木(オーク材)</td></tr>
      <tr><td>カラー</td><td>ナチュラル、ダークブラウン</td></tr>
    </table>
  </details>

  <details class="spec-group">
    <summary>機能・特徴</summary>
    <ul class="feature-list">
      <li>✅ 引き出し2段(フルスライドレール)</li>
      <li>✅ ケーブル配線穴付き</li>
      <li>✅ 耐荷重:天板30kg</li>
      <li>✅ 組み立て時間:約60分</li>
    </ul>
  </details>
</div>

ダッシュボード用パネル

<div class="dashboard-panels">
  <div class="panel-accordion">
    <details open>
      <summary>📊 今月の売上</summary>
      <div class="dashboard-content">
        <div class="metric-card">
          <span class="metric-value">¥1,250,000</span>
          <span class="metric-change">+15.3% ↗️</span>
        </div>
      </div>
    </details>

    <details>
      <summary>👥 アクティブユーザー</summary>
      <div class="dashboard-content">
        <div class="metric-card">
          <span class="metric-value">8,432</span>
          <span class="metric-change">+3.2% ↗️</span>
        </div>
      </div>
    </details>
  </div>
</div>

パフォーマンス最適化

遅延読み込み

class LazyAccordion {
  constructor(container) {
    this.container = container;
    this.headers = container.querySelectorAll('.accordion-header');
    this.loadedContents = new Set();
    this.init();
  }

  init() {
    this.headers.forEach((header, index) => {
      header.addEventListener('click', (e) => {
        this.loadContentIfNeeded(index);
        this.toggle(e.currentTarget);
      });
    });
  }

  loadContentIfNeeded(index) {
    if (this.loadedContents.has(index)) return;

    const content = this.headers[index].nextElementSibling;
    const placeholder = content.querySelector('.content-placeholder');
    
    if (placeholder) {
      // 重いコンテンツを動的に読み込み
      setTimeout(() => {
        placeholder.innerHTML = this.generateHeavyContent(index);
        this.loadedContents.add(index);
      }, 100);
    }
  }

  generateHeavyContent(index) {
    // 実際のプロジェクトではAjaxでデータを取得
    return `
      <div class="loaded-content">
        <p>動的に読み込まれたコンテンツ ${index + 1}</p>
        <div class="content-grid">
          ${Array.from({length: 10}, (_, i) => 
            `<div class="grid-item">項目 ${i + 1}</div>`
          ).join('')}
        </div>
      </div>
    `;
  }
}

CSS最適化

/* ハードウェアアクセラレーションを活用 */
.accordion-content {
  transform: translateZ(0);
  backface-visibility: hidden;
}

/* 効率的なアニメーション */
.accordion-content {
  will-change: max-height;
  transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

/* 不要な再描画を避ける */
.accordion-header {
  contain: layout style paint;
}

ブラウザ対応とフォールバック

details要素のサポート確認

function supportsDetails() {
  const details = document.createElement('details');
  return 'open' in details;
}

if (!supportsDetails()) {
  // フォールバック用のJavaScriptアコーディオンを使用
  initJavaScriptAccordion();
} else {
  // ネイティブのdetails要素を使用
  enhanceDetailsElements();
}

IE11対応のフォールバック

/* IE11用のフォールバック */
@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
  details {
    display: block;
  }
  
  summary {
    display: block;
    cursor: pointer;
  }
  
  details > *:not(summary) {
    display: none;
  }
  
  details[open] > *:not(summary) {
    display: block;
  }
}

よくある問題と解決法

アニメーションが滑らかでない

/* 問題:カクカクしたアニメーション */
.accordion-content {
  transition: height 0.3s ease;
}

/* 解決:max-heightを使用 */
.accordion-content {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.3s ease;
}

.accordion-content.active {
  max-height: 1000px; /* 十分な高さを指定 */
}

モバイルでの操作性

/* タッチデバイス対応 */
.accordion-header {
  min-height: 44px; /* タッチターゲットサイズ */
  touch-action: manipulation; /* ダブルタップズームを無効 */
}

@media (hover: none) and (pointer: coarse) {
  .accordion-header:hover {
    background: inherit; /* モバイルではホバー効果を無効 */
  }
}

SEOとアクセシビリティ

<!-- 検索エンジンフレンドリーな構造 -->
<article class="faq-item" itemscope itemtype="https://schema.org/Question">
  <details>
    <summary itemprop="name">
      <h3>返品はできますか?</h3>
    </summary>
    <div itemprop="acceptedAnswer" itemscope itemtype="https://schema.org/Answer">
      <div itemprop="text">
        <p>商品到着後7日以内であれば返品可能です。</p>
      </div>
    </div>
  </details>
</article>

まとめ

HTMLアコーディオンの実装方法は用途に応じて選択することが重要です。

手法の使い分け

方法適用場面メリット注意点
<details>シンプルなFAQJavaScript不要、軽量カスタマイズ制限あり
CSS + JS高度なデザイン完全カスタマイズ可能実装コストが高い
ライブラリ大規模サイト豊富な機能、安定性ファイルサイズが大きい

ベストプラクティス

  1. 適切な手法選択:要件に応じた実装方法の選択
  2. アクセシビリティ重視:キーボード操作、スクリーンリーダー対応
  3. パフォーマンス考慮:不要なアニメーション、遅延読み込み
  4. ユーザビリティ:直感的な操作、適切なフィードバック
  5. SEO最適化:構造化データ、セマンティックHTML

実装の段階的アプローチ

レベル1:基本実装

  • <details>要素による最小限の実装
  • 基本的なCSSスタイリング
  • シンプルなFAQやヘルプセクション

レベル2:中級実装

  • JavaScriptによる動的制御
  • アニメーション効果の追加
  • 複数開閉やグループ機能

レベル3:上級実装

  • アクセシビリティ完全対応
  • パフォーマンス最適化
  • 高度なカスタマイズとブランディング

プロジェクトでの活用指針

小規模サイト(個人ブログ、小企業サイト)

  • 推奨<details>要素 + 基本CSS
  • 理由:軽量、保守性、十分な機能

中規模サイト(コーポレートサイト、ECサイト)

  • 推奨:JavaScript実装 + カスタムCSS
  • 理由:ブランド統一、高いカスタマイズ性

大規模サイト(プラットフォーム、SaaS)

  • 推奨:フレームワーク + 最適化ライブラリ
  • 理由:保守性、拡張性、チーム開発対応

今後の発展性

CSS の新機能

/* CSS アニメーション API(将来) */
@view-transition {
  navigation: auto;
}

.accordion-content {
  view-transition-name: accordion-content;
}

/* Container Queries(一部ブラウザ対応済み) */
@container (min-width: 300px) {
  .accordion-item {
    display: flex;
  }
}

Web Components での実装

class AccordionElement extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #ccc;
          border-radius: 4px;
        }
        
        .header {
          padding: 1rem;
          cursor: pointer;
          background: #f5f5f5;
        }
        
        .content {
          padding: 1rem;
          display: none;
        }
        
        :host([open]) .content {
          display: block;
        }
      </style>
      <div class="header">
        <slot name="header"></slot>
      </div>
      <div class="content">
        <slot name="content"></slot>
      </div>
    `;
    
    this.querySelector('.header').addEventListener('click', () => {
      this.hasAttribute('open') 
        ? this.removeAttribute('open')
        : this.setAttribute('open', '');
    });
  }
}

customElements.define('my-accordion', AccordionElement);

使用例:

<my-accordion>
  <span slot="header">アコーディオンヘッダー</span>
  <div slot="content">
    <p>アコーディオンの内容です。</p>
  </div>
</my-accordion>

フレームワーク別実装例

React での実装

import React, { useState } from 'react';

const Accordion = ({ items }) => {
  const [openItems, setOpenItems] = useState(new Set());

  const toggleItem = (index) => {
    const newOpenItems = new Set(openItems);
    newOpenItems.has(index) 
      ? newOpenItems.delete(index)
      : newOpenItems.add(index);
    setOpenItems(newOpenItems);
  };

  return (
    <div className="accordion">
      {items.map((item, index) => (
        <div key={index} className="accordion-item">
          <button
            className={`accordion-header ${openItems.has(index) ? 'active' : ''}`}
            onClick={() => toggleItem(index)}
            aria-expanded={openItems.has(index)}
          >
            <span>{item.title}</span>
            <span className="accordion-icon">
              {openItems.has(index) ? '▲' : '▼'}
            </span>
          </button>
          <div className={`accordion-content ${openItems.has(index) ? 'active' : ''}`}>
            <div className="accordion-body">
              {item.content}
            </div>
          </div>
        </div>
      ))}
    </div>
  );
};

export default Accordion;

Vue.js での実装

<template>
  <div class="accordion">
    <div 
      v-for="(item, index) in items" 
      :key="index" 
      class="accordion-item"
    >
      <button
        :class="['accordion-header', { active: openItems.has(index) }]"
        @click="toggleItem(index)"
        :aria-expanded="openItems.has(index)"
      >
        <span>{{ item.title }}</span>
        <span class="accordion-icon">
          {{ openItems.has(index) ? '▲' : '▼' }}
        </span>
      </button>
      <transition name="accordion">
        <div 
          v-show="openItems.has(index)"
          class="accordion-content"
        >
          <div class="accordion-body">
            <div v-html="item.content"></div>
          </div>
        </div>
      </transition>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Accordion',
  props: {
    items: {
      type: Array,
      required: true
    }
  },
  data() {
    return {
      openItems: new Set()
    };
  },
  methods: {
    toggleItem(index) {
      if (this.openItems.has(index)) {
        this.openItems.delete(index);
      } else {
        this.openItems.add(index);
      }
      this.$forceUpdate(); // Setの変更を検知させる
    }
  }
};
</script>

<style scoped>
.accordion-enter-active, .accordion-leave-active {
  transition: all 0.3s ease;
}
.accordion-enter-from, .accordion-leave-to {
  opacity: 0;
  transform: translateY(-10px);
}
</style>

テスト戦略

単体テスト

// Jest + Testing Library での例
import { render, fireEvent, screen } from '@testing-library/react';
import Accordion from './Accordion';

test('アコーディオンの開閉動作', () => {
  const items = [
    { title: 'テスト項目', content: 'テスト内容' }
  ];
  
  render(<Accordion items={items} />);
  
  const header = screen.getByText('テスト項目');
  const content = screen.getByText('テスト内容');
  
  // 初期状態では閉じている
  expect(content).not.toBeVisible();
  
  // クリックで開く
  fireEvent.click(header);
  expect(content).toBeVisible();
  
  // 再クリックで閉じる
  fireEvent.click(header);
  expect(content).not.toBeVisible();
});

アクセシビリティテスト

import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('アクセシビリティ違反がないこと', async () => {
  const { container } = render(<Accordion items={mockItems} />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

デバッグとトラブルシューティング

よくあるバグと対処法

  1. アニメーションのちらつき
/* 問題:要素が一瞬表示される */
.accordion-content {
  opacity: 0;
  max-height: 0;
  transition: all 0.3s ease;
}

.accordion-content.active {
  opacity: 1;
  max-height: 500px;
}
  1. 高さの計算エラー
// 問題:動的コンテンツで高さが不正確
// 解決:MutationObserver で監視
const resizeObserver = new ResizeObserver(entries => {
  entries.forEach(entry => {
    const content = entry.target;
    if (content.classList.contains('active')) {
      content.style.maxHeight = content.scrollHeight + 'px';
    }
  });
});

document.querySelectorAll('.accordion-body').forEach(el => {
  resizeObserver.observe(el);
});
  1. フォーカス管理の問題
// 解決:適切なフォーカス移動
toggleAccordion(header) {
  const content = header.nextElementSibling;
  const isExpanded = header.getAttribute('aria-expanded') === 'true';
  
  if (!isExpanded) {
    // 開く時は最初のフォーカス可能要素に移動
    const firstFocusable = content.querySelector('a, button, input, textarea, select');
    if (firstFocusable) {
      setTimeout(() => firstFocusable.focus(), 300);
    }
  }
}

保守性とスケーラビリティ

設定可能なオプション

class ConfigurableAccordion {
  constructor(container, options = {}) {
    this.options = {
      allowMultiple: false,
      animationDuration: 300,
      autoClose: true,
      persistState: false,
      ...options
    };
    
    this.container = container;
    this.state = this.loadState();
    this.init();
  }
  
  loadState() {
    if (!this.options.persistState) return new Set();
    
    const saved = localStorage.getItem(`accordion-${this.container.id}`);
    return saved ? new Set(JSON.parse(saved)) : new Set();
  }
  
  saveState() {
    if (!this.options.persistState) return;
    
    localStorage.setItem(
      `accordion-${this.container.id}`, 
      JSON.stringify([...this.state])
    );
  }
}

コメント

タイトルとURLをコピーしました