アコーディオンを一瞬で開閉するだけでなく、なめらかに動かすと、UIがぐっと自然になります。難しいのは、高さが「内容によって決まる」ことです。CSSのtransitionは、height: autoには効きません。そこで、中身の高さを測って、数値で動かします。この記事では、Vue 3(<script setup>)で、その方法を2通り紹介します。ボタンの作り方やアクセシビリティなど、アコーディオンの基本はシンプルなアコーディオンを作成する方法、<Transition>の基礎はtransitionコンポーネントを使ってアニメーションを実装する方法で解説しています。
方法A:Transitionのフックで高さを動かす
開閉する要素を<Transition>で囲み、JavaScriptフックでheightを動かします。開くときは、0pxから中身の高さ(scrollHeight)まで広げ、閉じるときは、今の高さから0pxまで縮めます。フックの引数elは、アニメーションする要素そのものです。
<!-- AccordionItem.vue(方法A:Transition のフックで高さを動かす) -->
<script setup>
import { ref } from 'vue'
defineProps({
id: { type: String, required: true },
title: { type: String, required: true }
})
const isOpen = ref(false)
// el は、アニメーションさせる要素(v-show を付けた div)そのもの
function onEnter(el) {
el.style.height = '0px'
el.getBoundingClientRect() // 0px を確定させる(強制リフロー)
el.style.height = el.scrollHeight + 'px' // 中身の高さまで広げる
}
function onAfterEnter(el) {
el.style.height = '' // 高さの固定を解除(中身の増減に追従)
}
function onLeave(el) {
el.style.height = el.offsetHeight + 'px' // 今の高さを固定
el.getBoundingClientRect()
el.style.height = '0px'
}
function onAfterLeave(el) {
el.style.height = ''
}
</script>
<template>
<div class="accordion-item">
<h3 class="accordion-heading">
<button
type="button"
class="accordion-header"
:aria-expanded="isOpen"
:aria-controls="`${id}-panel`"
@click="isOpen = !isOpen"
>
{{ title }}
</button>
</h3>
<Transition
name="accordion"
@enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
@after-leave="onAfterLeave"
>
<div v-show="isOpen" :id="`${id}-panel`" class="accordion-content">
<div class="accordion-body">
<slot />
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.accordion-header {
display: block;
width: 100%;
padding: 10px;
text-align: left;
font: inherit;
cursor: pointer;
background: #f1f1f1;
border: none;
}
.accordion-heading { margin: 0; }
.accordion-content { overflow: hidden; }
.accordion-body { padding: 10px; }
/* Vue はこのクラスの transition を見て、アニメーションの終了を自動で検出する */
.accordion-enter-active,
.accordion-leave-active {
transition: height 0.3s ease;
}
/* 「動きを減らす」設定のユーザーには、アニメーションなしで開閉する */
@media (prefers-reduced-motion: reduce) {
.accordion-enter-active,
.accordion-leave-active {
transition: none;
}
}
</style>
使うときは、idとtitleを渡し、中身をスロットに書きます。複数並べても、それぞれが独立して開閉します。
<script setup> import AccordionItem from './AccordionItem.vue' </script> <template> <AccordionItem id="faq1" title="質問1">答え1</AccordionItem> <AccordionItem id="faq2" title="質問2">答え2</AccordionItem> <AccordionItem id="faq3" title="質問3">答え3</AccordionItem> </template>
フックとCSSの両方に transition を書く理由
フックの中で高さを変えているのに、CSSにも.accordion-enter-activeのtransitionを書いているのは、冗長ではありません。公式の説明どおり、CSSのトランジションがあれば、Vueはその終了を自動で検出します。このため、フックにdoneを渡さなくても、アニメーションが終わった後で@after-enterなどが呼ばれます。このCSSを消すと、終了のタイミングが分からなくなります。
JavaScriptだけで動きを制御する場合は、公式の推奨どおり:css="false"を付け、フックのdone()を自分で呼びます。
<!-- JavaScript だけで制御する場合は :css="false" と done が必要 -->
<Transition :css="false" @enter="onEnter" @leave="onLeave">
<div v-show="isOpen">...</div>
</Transition>
<script setup>
function onEnter(el, done) {
// アニメーションが終わったら done() を呼ぶ
// 例:el.animate(...).finished.then(done)
}
</script>
方法B:ref(テンプレート参照)で高さを測る
方法Aのelは、フックが渡してくれる要素です。一方、ref(テンプレート参照)は、コンポーネントの任意の場所から要素に触れるための仕組みです。方法Bは、中身のdivにrefを付け、開くときの高さを測って、:styleに反映します。<Transition>を使わず、CSSのtransitionだけで動かします。
<!-- AccordionItemRef.vue(方法B:テンプレート参照 ref で高さを測る) -->
<script setup>
import { ref } from 'vue'
defineProps({
id: { type: String, required: true },
title: { type: String, required: true }
})
const isOpen = ref(false)
const body = ref(null) // テンプレート参照:中身の div
const height = ref(0)
function toggle() {
isOpen.value = !isOpen.value
// 開くときは中身の高さを測って設定し、閉じるときは 0 に戻す
height.value = isOpen.value ? body.value.offsetHeight : 0
}
</script>
<template>
<div class="accordion-item">
<h3 class="accordion-heading">
<button
type="button"
class="accordion-header"
:aria-expanded="isOpen"
:aria-controls="`${id}-panel`"
@click="toggle"
>
{{ title }}
</button>
</h3>
<div
:id="`${id}-panel`"
class="panel"
:class="{ 'is-open': isOpen }"
:style="{ height: height + 'px' }"
>
<div ref="body" class="accordion-body">
<slot />
</div>
</div>
</div>
</template>
<style scoped>
.panel {
overflow: hidden;
visibility: hidden;
/* 閉じ終わってから visibility を切り替え、Tab で中に入れないようにする */
transition: height 0.3s ease, visibility 0s linear 0.3s;
}
.panel.is-open {
visibility: visible;
transition: height 0.3s ease, visibility 0s;
}
</style>
閉じているパネルの中のリンクやボタンに、Tabキーで入れてしまわないよう、visibilityを閉じ終わりに切り替えています。
2つの方法の使い分け
- 方法A:Vueの仕組みに沿っており、開閉のたびに高さを測り直すため、中身が変わっても動きます。
v-showと相性がよいです。 - 方法B:仕組みが分かりやすく、
<Transition>が不要です。ただし、開いている間に中身の高さが変わっても、heightは更新されません。
迷ったら、方法Aをおすすめします。
注意点
heightのアニメーションは負荷が高い
公式は、アニメーションにはtransformとopacityを使うよう勧めています。レイアウトの再計算が不要で、GPUでの描画が使えるためです。heightやmarginは負荷が高いとされます。アコーディオンは、中身の高さで開くという性質上、heightを動かさざるを得ない代表例です。1度に開閉する量を小さくする、ページ内に大量に並べすぎない、といった配慮をしてください。
動きを減らす設定への配慮
OSで「動きを減らす」設定をしているユーザーには、アニメーションを止めます。上のコードのprefers-reduced-motionの指定が、その部分です。
連打したときの動き
開閉の途中で素早くクリックすると、アニメーションが途中で切り替わり、動きが跳ねることがあります。公式には、中断を扱う@enter-cancelledと@leave-cancelledのフックがあります(後者はv-showのトランジションのみ)。ただし、これらで高さをリセットすると、途中の高さから動かすのが難しくなるため、上のコードは、あえて扱っていません。
関連記事
v-showとv-ifの違いはv-ifで条件分岐させる方法- リストを増減させるアニメーションはtransitionコンポーネントを使ってアニメーションを実装する方法の
<TransitionGroup>
よくある質問(FAQ)
autoは数値ではないため、CSSが途中の値を計算できないからです。中身の高さをscrollHeightなどで測り、pxの数値で動かします。after-enterなどが想定外のタイミングで呼ばれます。JavaScriptだけで制御するなら、:css="false"とdone()が必要です。<Transition>のフックを使う方法Aでは、フックが渡すelで高さを測れます。refは、フックを使わずに高さを測りたいときなど、要素に自分で触れたいときに使います。height: 0にしても、中の要素はフォーカスできます。方法Aはv-showのdisplay: noneで防げます。方法Bは、visibility: hiddenを閉じ終わりに切り替えて防ぎます。まとめ
高さが内容で決まるアコーディオンは、中身の高さを測ってpxで動かすのが基本です。<Transition>のフックを使う方法Aと、refで測る方法Bがあり、通常は方法Aが扱いやすいです。heightの負荷、動きを減らす設定、連打時の動きに気を配りながら、aria-expandedを付けた<button>と組み合わせてください。

