ホームページやブログで
「クリックすると内容が開いたり閉じたりするメニュー」
を見たことはありませんか?
これをアコーディオンメニューと呼びます。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> | シンプルなFAQ | JavaScript不要、軽量 | カスタマイズ制限あり |
CSS + JS | 高度なデザイン | 完全カスタマイズ可能 | 実装コストが高い |
ライブラリ | 大規模サイト | 豊富な機能、安定性 | ファイルサイズが大きい |
ベストプラクティス
- 適切な手法選択:要件に応じた実装方法の選択
- アクセシビリティ重視:キーボード操作、スクリーンリーダー対応
- パフォーマンス考慮:不要なアニメーション、遅延読み込み
- ユーザビリティ:直感的な操作、適切なフィードバック
- 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();
});
デバッグとトラブルシューティング
よくあるバグと対処法
- アニメーションのちらつき
/* 問題:要素が一瞬表示される */
.accordion-content {
opacity: 0;
max-height: 0;
transition: all 0.3s ease;
}
.accordion-content.active {
opacity: 1;
max-height: 500px;
}
- 高さの計算エラー
// 問題:動的コンテンツで高さが不正確
// 解決: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);
});
- フォーカス管理の問題
// 解決:適切なフォーカス移動
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])
);
}
}
コメント