メインコンテンツまでスキップ

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

CSS

広告

jQueryのslideToggle()のような要素をスライドしながら表示非表示切り替えるアニメーション。かつてはJSで要素の高さを取得する必要があったりCSSだけで行うとアニメーションにムラがあったり…とjQueryを使わないと何かと面倒な実装が必要でしたが、現在ではCSS2行を用意して、そのうちの1行を切り替えるだけで実装が可能です(transitionプロパティは除く)。しかも全モダンブラウザ対応済みです。

結論

実装方法は至極簡単で、開閉されるパネル要素にdisplay:gridを指定し、grid-template-rowsプロパティの値を0fr1frに切り替えるだけです。

overflow:hiddenを指定した子要素1つが必要です。

.accordion-panel {
display: 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プロパティを使用したスライドアニメーションのサンプルを作成しました。

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

アコーディオンの実装には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; // 初期値のlist-item以外
cursor: pointer;
&::-webkit-details-marker {
display: none;
}
}
.panel {
display: 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に至っては開きません。requestAnimationFrame1回だとFirefoxで不具合を起こします。


以上が今回の実装のポイントです。

おまけ:heightを利用したアニメーションとの比較

せっかくなのでheightを利用したアコーディオンのサンプルを用意しました。僕は論理プロパティ優先の実装を行っているのでheightの変わりにblock-sizeを使用しています。

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

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)プロパティを使用した方法でアコーディオンを実装しています。