アコーディオンのスライドアニメーションはCSS2行で実装できる

広告
jQueryのslideToggle()
のような要素をスライドしながら表示非表示切り替えるアニメーション。かつてはJSで要素の高さを取得する必要があったりCSSだけで行うとアニメーションにムラがあったり…とjQueryを使わないと何かと面倒な実装が必要でしたが、現在ではCSS2行を用意して、そのうちの1行を切り替えるだけで実装が可能です(transition
プロパティは除く)。しかも全モダンブラウザ対応済みです。
結論
実装方法は至極簡単で、開閉されるパネル要素にdisplay:grid
を指定し、grid-template-rows
プロパティの値を0fr
↔1fr
に切り替えるだけです。
※overflow:hidden
を指定した子要素1つが必要です。
.accordion-panel { display: block grid; transition: grid-template-rows 0.5s; grid-template-rows: 0fr;}
.accordion-panel > * { overflow: hidden;}
.accordion-panel[data-is-active='true'] { grid-template-rows: 1fr;}
たったこれだけです。合計5行あるのは気にしないでください。
grid-template-rows
アニメーションのメリットは次のとおりです。
- 記述量が圧倒的に少ないのでコストが低い。コードの簡略化に繋がる。
- JavaScriptでパネルの高さを計算する必要が無い。
- heightを固定値にする方法と違って、改行などで内部要素の高さが変化してもパネルの高さが自動的に調整されて溢れることがない。
- 全モダンブラウザ対応済み。
あたりでしょうか?CSSだけでアニメーションの指定が完結する故に導入コストが低いのは大きなメリットだと思います。
サンプルと実装例
grid-template-rows
プロパティを使用したスライドアニメーションのサンプルを作成しました。
アコーディオンの実装にはdetails
要素を使用しています。アコーディオンの実装には色々やり方はありますが、次のようなメリットから原則的にはdetails
要素で実装することを推奨します。
- 開閉状態を読み上げることでアクセシビリティが向上する
- ページ内検索で内包されるコンテンツがヒットした際に自動でオープンにしてくれる
- デフォルトの開閉動作にJSが必要ないため、JSが動かない環境でも開閉できる
ただし、summary要素の中に見出しタグを含めると見出しのroleが消失してしまうなどの問題もあるため、section > h3
のようなマークアップが適切な場所でアコーディオンを取り入れる際は別のアプローチが必要になります。
HTML
<details id="js-accordion"> <summary>ラベル</summary> <div class="panel"> <div class="inner"> <ul> <li>リスト1</li> <li>リスト2</li> <li>リスト3</li> </ul> </div> </div></details>
details
の中にsummary
とパネルになる単一のdiv
要素を配置し、そのパネルの中にoverflow:hidden
を指定した単一のdiv
要素を配置、その中にコンテンツを入れます。
余分なインナー要素が必要となりますが、これがないとアニメーションがうまく機能しないので忘れないようにしましょう。また、overflow:hidden
の代わりにcontain
プロパティを指定してもうまく動きませんでした。
CSS
必要な部分だけ抜粋して紹介します。なお、grid-template-rows
プロパティについては後述する理由からJSでセットします。
.summary { display: block flow; // 初期値のlist-item以外 cursor: pointer;
&::-webkit-details-marker { display: none; }}
.panel { display: block grid; transition: grid-template-rows 0.5s;}
.inner { overflow: hidden;}
summary
要素にはdisplay:block
などを指定するとブラウザ標準の三角アイコンを消すことができます。これだけではSafariで消えないので、Safari用に::-webkit-details-marker
疑似要素をdisplay:none
します。
パネル要素にはdisplay:grid
と任意のtransition
を指定します。
インナー要素には前述したoverflow:hidden
を指定します。パネルにpaddingを持たせたい場合はインナーの子要素に指定してください。borderはパネルとインナーどちらにつけても問題ありません。
JavaScript
JSは長いですが次のようにしました。
const initializeDetailsAccordion = (details) => { const summary = details.querySelector('summary') const panel = details.querySelector('summary + *')
if (!(details && summary && panel)) return // 必要要素が揃ってない場合は処理をやめる
let isTransitioning = false // 連打防止フラグ
const onOpen = () => { if (details.open || isTransitioning) { return } isTransitioning = true panel.style.gridTemplateRows = '0fr' details.setAttribute('open', '') requestAnimationFrame(() => { requestAnimationFrame(() => { panel.style.gridTemplateRows = '1fr' }) }) panel.addEventListener( 'transitionend', () => { isTransitioning = false }, { once: true }, ) }
const onClose = () => { if (!details.open || isTransitioning) { return } isTransitioning = true panel.style.gridTemplateRows = '0fr' panel.addEventListener( 'transitionend', () => { details.removeAttribute('open') panel.style.gridTemplateRows = '' isTransitioning = false }, { once: true }, ) }
summary.addEventListener('click', (event) => { event.preventDefault()
if (details.open) { onClose() } else { onOpen() } })}
// Use🤞const accordion = document.getElementById('js-accordion')initializeDetailsAccordion(accordion)
連打防止フラグを用意しつつ、details要素がopenか否かで処理を分けます。ポイントは次のとおりです。
- JSが動かない環境でも動作するというメリットを潰さないために、JS側で
grid-template-rows
プロパティの値をセットする。 - onOpen関数では始めに
grid-template-rows
の値を0fr
にセットをし、requestAnimationFrame
を2重に呼んでから1fr
に再セットする(重要) - transitionendイベントでアニメーションの完了後に連打防止フラグを切り替えつつ、onCloseの時は
open
属性をリムーブしつつgrid-template-rows
プロパティの値を初期値にする。閉じた後にgrid-template-rows
プロパティが0fr
のままだとその後ページ検索した際に開かなくなります。
重要なのはonOpen関数ではrequestAnimationFrame
を2重に呼んでアニメーションを走らせるということです。これを怠ると初回の開いた際のアニメーションが走らなくなり、Safariに至っては開きません。requestAnimationFrame
1回だとFirefoxで不具合を起こします。
以上が今回の実装のポイントです。
おまけ:heightを利用したアニメーションとの比較
せっかくなのでheightを利用したアコーディオンのサンプルを用意しました。僕は論理プロパティ優先の実装を行っているのでheight
の変わりにblock-size
を使用しています。
grid-template-rows
の実装との違いはHTML、CSSはそこまで大きな変化はありません。余分なインナーが必要なくなったので取り除いたくらいです。
JSは次の通り。
const initializeDetailsAccordion = (details) => { const summary = details.querySelector('summary') const panel = details.querySelector('summary + *')
if (!(details && summary && panel)) return // 必要要素が揃ってない場合は処理をやめる
let isTransitioning = false // 連打防止フラグ
const onOpen = () => { if (details.open || isTransitioning) { return } isTransitioning = true details.setAttribute('open', '') panel.style.blockSize = '0px' requestAnimationFrame(() => { requestAnimationFrame(() => { panel.style.blockSize = `${panel.scrollHeight}px` }) }) panel.addEventListener( 'transitionend', () => { panel.style.blockSize = '' isTransitioning = false }, { once: true }, ) }
const onClose = () => { if (!details.open || isTransitioning) { return } isTransitioning = true panel.style.blockSize = `${panel.scrollHeight}px` requestAnimationFrame(() => { requestAnimationFrame(() => { panel.style.blockSize = '0' }) }) panel.addEventListener( 'transitionend', () => { details.removeAttribute('open') panel.style.blockSize = '' isTransitioning = false }, { once: true }, ) }
summary.addEventListener('click', (event) => { event.preventDefault()
if (details.open) { onClose() } else { onOpen() } })}
const accordion = document.getElementById('js-accordion')initializeDetailsAccordion(accordion)
- onOpenが発動したタイミングで
block-size:0px
をセット。その後requestAnimationFrame
を2重に呼んでopen
属性が取り除かれるのを待ってからパネルの高さををセットします。transition
を使用してスムーズに表示を切り替える場合、開始と終了の高さがとなりますが、scrollHeight
は要素のビューポート内に収まらない内容を含む要素の完全な表示高さを取得します。これにより適切な高さでアニメーションを走らせることができます。 - onOpenがtransitionendした際に
block-size
の値を初期値に戻します。これにより開いている途中で内容物の高さが変動しても自動的に調整されて溢れることがなくなります。 - onCloseする際は
block-size
に現在の高さをセットした後、requestAnimationFrame
を2重に呼んでから値に0px
をセットします。この処理によって閉じる際にtransitionを走らせることが可能となります。transitionendしてからblock-size
の値を初期値に戻し、ページ内検索でも開けるように対応を行います。
grid-template-rows
との比較ポイントとしては
grid-template-rows
アニメーションの際は必要であった余分なインナーが不要block-size
プロパティの値を適切に切り替えることでgrid-template-rows
アニメーションの要件を満たしたような実装ができる- 数行JSが長くなるが、複数のアコーディオンを実装する際は関数を使い回せば問題ない
…あれ?grid-template-rows
アニメーションいらなくない…?
当ブログではblock-size
(height
)プロパティを使用した方法でアコーディオンを実装しています。