【完全版】details / summaryで作るアコーディオンアニメーション

【完全版】details / summaryで作るアコーディオンアニメーション

セマンティックなdetails / summaryタグを使用して、アコーディオンアニメーションを実装する方法を紹介します。

今回作るアコーディオンは、以下のガイドラインに従って作成しました。

  • アクセシビリティ/ユーザビリティを妨げない
  • ブラウザ間で一貫した動作を確保する
  • アイコンを独自のものにする
  • detalsのインナー1つだけで高さを制御させる

特に最後の、インナー1つだけで高さを制御させるという点にとても苦労しましたが、今回試行錯誤した結果、目標通りに完成させることができました。

詳しくは読み進めてみてください。

WordPressのおすすめサーバー

特徴
  • 料金が安い
  • WordPressが超高速
  • ドメイン永久無料
  • 安心の実績とサポート体制

新規も乗り換えも

目次

完成版details / summaryアコーディオン

See the Pen details / summary accordion +icon by hisa (@hisaaashi) on CodePen.

全体のコードは後ほど、まずは要点を解説します。

開閉の仕組み

前提として、デフォルトのopen属性で開閉を制御するのではなく、JavaScriptからis-openクラスを付与して開閉を制御しています。

Safariでは、open属性を付与させてからでないと高さを取得出来ないため(display:noneのような状態)、オープン時最初にセットします。

高さを取得した後、max-heightを0にセットし、短い遅延(20ms)の後に規定の高さまで開くという具合です。

20msより短いと開閉アニメーションがスムーズにならない場合があります。

閉じる時は、現在の高さから短い遅延の後にmax-heightを0にセット、そしてopen属性を取り除けば良いのですが、完全に閉じてからopen属性を取り除かないとアニメーションが即座に終わってしまうため、一番最後に削除しています。

連打防止

アニメーション中に何度もクリックすると、動作が乱れてしまうため、isAnimatingtrueの時はクリックイベントを受け付けないようにリターンして処理を終了しています。

オープン中だけでも動作が安定していたので、クローズ時は特に設定していません。

open属性とis-openクラスを同期


open属性が存在する場合、それに応じてis-openクラスを追加し、open属性が存在しない場合にはis-openクラスも削除する動作を、関数としてまとめています。

この関数はページの読み込み時に呼び出され、コンテンツを最初から開いた状態で表示したい場合、open属性を追加するだけで済むようになります。

また、details/summaryタグはページ内検索時に自動的にopen属性が追加され、閉じたコンテンツが自動的に表示される仕組みですが、今回はis-openクラスを適用しないと表示されないため、toggleイベントが発生する際にもこの関数を使って同期させるようにしました。

重要:高さの取得方法

.details-contentに直接paddingを指定するとオープン時にカクついてしまうため、divを二重にして子要素の方にpaddingを指定する方法をよく見かけます。

divを二重にする例

<details class="details">
  <summary class="summary">タイトルが入ります</summary>
  <div class="details-content">
    <div class="details-content-inner">
      <p>アコーディオンのコンテンツが入ります</p>
    </div>
  </div>
</details>

しかし、HTMLとしては少し冗長なため、.details-contentひとつで制御できるように考えました。

それは以下のように、コンテンツをbox-sizing:content-boxに切り替えるというアプローチです。

<details class="details">
  <summary class="summary">タイトルが入ります</summary>
  <div class="details-content">
    <p>アコーディオンのコンテンツが入ります</p>
  </div>
</details>
/* コンテンツ */
.details-content {
	box-sizing: content-box;
	padding-block: 0;
}

/* コンテンツ(オープン時) */
.is-open .details-content {
	padding-block: 20px;
}
  • border-boxは高さにpaddingが含まれる
  • content-boxは高さにpaddingが含まれない

カクつく原因と対策

オープン中に取得できる高さは、is-openクラスが付与される前の高さです(上下のpaddingは0)。

つまり、実際のオープン状態の高さが100pxで上下のpaddingが20pxある場合、閉じた状態から得られる高さは80pxになります。

max-heightis-openクラスが付与されると、80pxまでの範囲ではアニメーションがスムーズに行われますが、アニメーション終了後styleが削除された時に、実際の高さ100pxに戻りカクつく現象が生じます。

この問題を改善するためにbox-sizing:content-boxを設定し、開いた状態でも閉じた状態でも、上下のpaddingを含めない80pxの高さを制御するという理屈です。

これにより、パディングが高さの計算に影響を与えず、スムーズなアニメーションが実現されます。

管理人

言語化が非常に難しいのですが、分かりますか…?笑

さらに重要なポイントとして、高さを指定する際にはheightではなくmax-heightを使用することが挙げられます。

heightは確定的な値を指定するのに対し、max-heightは可変性を保ちつつも一定の制約を設ける方法です。

max-heightを使用しないと、滑らかなアニメーションが得られないことに注意が必要です。

余談:CSSについて

アイコンの前後の入れ替え

アイコン共通のgrid-columnの値を1か2にするだけで、位置をタイトルの前後選べるようにしています。

閉じるときのアニメーション

コンテンツが閉じる時のtransitionpadding .2s ease .1sというように.1sだけ遅延させて閉じるようにしています。

これにより、わずかな時間の遅延で高さのみが閉じ、コンテンツは縮小されないように見せる効果を生み出しています。

詳細ブロックをアコーディオンにさせる方法

WordPress6.3から追加された詳細ブロック(details / summary)を、こちらのコードを応用してアコーディオン化する方法を記事にしました。

良かったら覗いてみてください。

全体のコード

HTML

<div class="wrapper">

  <details class="details">
    <summary class="summary">タイトルが入りますか?</summary>
    <div class="details-content">
      <p>アコーディオンのコンテンツが入ります</p>
      <p>アコーディオンのコンテンツが入ります</p>
    </div>
  </details>
  
  <details class="details" open>
    <summary class="summary">タイトルが入りますか?</summary>
    <div class="details-content">
      <p>is-openクラスは自動で付与されます</p>
    </div>
  </details>
  
  <details class="details">
    <summary class="summary">タイトルが入りますか?</summary>
    <div class="details-content">
      <p>by <a href="https://blog-mi.com/details-summary/" target="_blank" rel="noopener noreferre">https://blog-mi.com/details-summary/</a>
      </p>
    </div>
  </details>

</div>

CSS

/* 幅の設定*/
.wrapper {
  max-width: 600px;
}

/* ===================
アコーディオン details/summary
====================== */
/* アコーディオン全体 */
.details {
	box-shadow: 0 0 0 1px #eee;
}

/* アコーディオンの間隔 */
.details + .details {
	margin-top:1em;
}

/* 三角アイコン削除(Safari) */
summary::-webkit-details-marker {
  display: none;
}

/* タイトル */
.summary {
	cursor: pointer;
	background-color: #eee;
	padding: 0.5em 1em 0.5em 1em;
	display: grid;
	grid-template-columns: auto 1fr;
	align-items: center;
	gap: 1em;
	/* アイコンのはみ出し防止 */
	overflow: hidden;
}

/* アイコン共通 */
.summary::before,
.summary::after {
	/* アイコンの位置:1or2 */
	grid-column: 2;
	grid-row: 1;
	justify-self: end;
	content: '';
	width: 18px;
	border-bottom: 1px solid #333;
}

/* アイコン(クローズ時) */
.summary::before {
	transform: rotate(-90deg);
	transition: transform .3s;
}

/* アイコン(オープン時) */
.is-open .summary::before {
	transform: rotate(0deg);
}

/* コンテンツ */
.details-content {
	box-sizing: content-box;
	overflow: hidden;
	margin: 0;
	padding: 0 1em;
	opacity:0;
	transition: padding .2s ease .1s, max-height .3s, opacity .7s;
}

/* コンテンツ(オープン時) */
.is-open .details-content {
	padding-block: 1em;
	opacity:1;
	transition: padding .3s, max-height .3s, opacity .7s;
}

JavaScript

/* ===================
アコーディオン details / summary
====================== */
const ANIMATION_TIME = 300;
const OFFSET_TIME = 20;

document.addEventListener('DOMContentLoaded', function () {

	const accordions = document.querySelectorAll('.details');
	accordions.forEach((accordion) => {
		
		let isAnimating = false; // アニメーション中かどうかを示すフラグ
		
		const title = accordion.querySelector('.summary');
		const content = accordion.querySelector('.details-content');
		
		// タイトルのクリックイベント
		title.addEventListener('click', (e) => {
			e.preventDefault();
			
			// アニメーション中はクリックイベントを受け付けない(連打防止)
			if (isAnimating) return;
			
			// オープン処理
			if (!accordion.open) {
				isAnimating = true; // アニメーション中(オープン時のみでも安定する)
				accordion.open = true; // コンテンツの高さを取得するためopen属性をセット
				
				const contentHeight = content.offsetHeight;
				
				// コンテンツの高さを0に設定して非表示にする
				content.style.maxHeight = 0;
				
				// オフセット時間後にアニメーションを開始
				setTimeout(() => {
					content.style.maxHeight = `${contentHeight}px`; // コンテンツの高さを元の高さに設定して表示する
					accordion.classList.add('is-open'); // オープン状態のクラスを追加
					
					// アニメーション完了後にリセット
					setTimeout(() => {
						content.removeAttribute("style");
						isAnimating = false; // アニメーション解除
					}, ANIMATION_TIME);
				}, OFFSET_TIME);
				
			// クローズ処理	
			} else if (accordion.open) {
				const contentHeight = content.offsetHeight;
				
 				// コンテンツの高さを元の高さに設定して表示する
				content.style.maxHeight = `${contentHeight}px`;
				
				// オフセット時間後にアニメーションを開始
				setTimeout(() => {
					content.style.maxHeight = 0; // コンテンツの高さを0に設定して非表示にする
					accordion.classList.remove('is-open');
					
					// アニメーション完了後にリセット
					setTimeout(() => {
						content.removeAttribute("style");
						accordion.open = false; // open属性を削除
					}, ANIMATION_TIME);
				}, OFFSET_TIME);
			}
		});
		
		// open属性とis-openクラスを同期させるための関数
		function syncOpenState() {
			const hasOpenClass = accordion.classList.contains('is-open');

			if (accordion.open && !hasOpenClass) {
				// open がセット is-open がない時
				accordion.classList.add('is-open');
			} else if (!accordion.open && hasOpenClass) {
				// open が削除 is-open がある時
				accordion.classList.remove('is-open');
			}
		}
		
		// 初期状態でオープン状態を同期する(クラスのつけ忘れ防止)
		syncOpenState();
		
		// ページ内検索で自動開閉した際に同期する
		accordion.addEventListener('toggle', () => {
			
			setTimeout(() => { // クリックした場合にもこのイベントが発生するためクリック時と同様オフセット時間後にオープン状態を同期する
				syncOpenState();
			}, OFFSET_TIME);
		});
	});
});

まとめ:予想以上に大変だった

完成すれば「そんなことか」となるのですが、原因を探るまでかなりの時間を要しました。

今回の要となるのは、box-sizing:content-boxmax-heightの指定です。

何か気づいたことや感想などあればお気軽にコメントくださいませ。

カスタマイズを自分でしてみませんか?

「あの機能追加したいな」「もう少しここを調整したいな」と思ったらWeb制作のスクール「デイトラ」がおすすめです…!

カリキュラムは3ヶ月分ですが、受講生は卒業後もずっと見放題!常に最新のコンテンツに更新されるため、情報が古くなるなんてこともありません。

価格はどのコースも10万円前後と他のスクールに比べても格安です。
なのに副業・転職に十分なスキルが身につきます。

管理人

私はWeb制作とWebデザインを受講し、現在フリーランスWebデザイナーとなりました。

他にも様々なコースが充実しているので、身につけたいスキルがあったら是非覗いてみてください。

コースの一部をご紹介

よかったらシェアしてね!

コメント

コメントする

目次