【Vue.js】アニメーション付きアコーディオンを実装する方法|transitionとrefを活用

【Vue.js】アニメーション付きアコーディオンを実装する方法|transitionとrefを活用 Vue.js

アコーディオンを一瞬で開閉するだけでなく、なめらかに動かすと、UIがぐっと自然になります。難しいのは、高さが「内容によって決まる」ことです。CSSのtransitionは、height: autoには効きません。そこで、中身の高さを測って、数値で動かします。この記事では、Vue 3(<script setup>)で、その方法を2通り紹介します。ボタンの作り方やアクセシビリティなど、アコーディオンの基本はシンプルなアコーディオンを作成する方法、<Transition>の基礎はtransitionコンポーネントを使ってアニメーションを実装する方法で解説しています。

スポンサーリンク

方法A:Transitionのフックで高さを動かす

開閉する要素を<Transition>で囲み、JavaScriptフックでheightを動かします。開くときは、0pxから中身の高さ(scrollHeight)まで広げ、閉じるときは、今の高さから0pxまで縮めます。フックの引数elは、アニメーションする要素そのものです。

Vue:AccordionItem.vue(方法A)
<!-- 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を渡し、中身をスロットに書きます。複数並べても、それぞれが独立して開閉します。

Vue:複数のアコーディオンを並べる
<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()を自分で呼びます。

Vue:JavaScriptのみで制御する場合
<!-- 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だけで動かします。

Vue:AccordionItemRef.vue(方法B)
<!-- 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のトランジションのみ)。ただし、これらで高さをリセットすると、途中の高さから動かすのが難しくなるため、上のコードは、あえて扱っていません。

関連記事

よくある質問(FAQ)

Qheight: autoにトランジションが効かないのはなぜですか?
Aautoは数値ではないため、CSSが途中の値を計算できないからです。中身の高さをscrollHeightなどで測り、pxの数値で動かします。
Qフックの中で高さを変えているのに、CSSにもtransitionが必要ですか?
A必要です。Vueはクラスに書かれたトランジションの終了を自動で検出します。これがないと、アニメーションの終了が分からず、after-enterなどが想定外のタイミングで呼ばれます。JavaScriptだけで制御するなら、:css="false"とdone()が必要です。
Qrefは必ず使う必要がありますか?
A必須ではありません。<Transition>のフックを使う方法Aでは、フックが渡すelで高さを測れます。refは、フックを使わずに高さを測りたいときなど、要素に自分で触れたいときに使います。
Q閉じたパネルの中身にTabキーで入れてしまいます。
Aheight: 0にしても、中の要素はフォーカスできます。方法Aはv-showのdisplay: noneで防げます。方法Bは、visibility: hiddenを閉じ終わりに切り替えて防ぎます。

まとめ

高さが内容で決まるアコーディオンは、中身の高さを測ってpxで動かすのが基本です。<Transition>のフックを使う方法Aと、refで測る方法Bがあり、通常は方法Aが扱いやすいです。heightの負荷、動きを減らす設定、連打時の動きに気を配りながら、aria-expandedを付けた<button>と組み合わせてください。