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

トランジション — 出入りのアニメーションを宣言する

13 章は「どこへ描くか」と「いつ描くか」を扱いました。この章は「どう出入りさせるか」です。<Transition> は 1 つの要素の出入りに、<TransitionGroup> はリストの出入りと並べ替えにアニメーションを付けます。

どちらもアニメーションそのものは書きません。やるのは、決まったタイミングでクラスを付け外しし、JavaScript のフックを呼ぶことだけです。実際の動きは CSS か自分で書いたコードが担います。

この章で学ぶこと

  • 6 つのクラスが付く順序と、「1 フレーム後」の実体
  • Vue がトランジションの終わりを判定する仕組みと、それが外れる場面
  • JavaScript のフックで done を待つかどうかが何で決まるか
  • appear を付けたときにフックがどう変わるか
  • TransitionGroup が並べ替えを追えるようになる条件
  • 他の組み込みコンポーネントと組んだときの見え方

出入りにクラスを配る

<Transition> は既定スロットの中身に enter / leave のアニメーションをかけます。公式ガイドが挙げる出入りのきっかけは 4 つです — v-if / v-show / <component :is> の切り替え / key の変更。

components/FadeBox.vue
<script setup lang="ts">
import { ref } from 'vue'

const show = ref(true)
</script>

<template>
<button type="button" @click="show = !show">切り替え</button>

<Transition name="fade">
<p v-if="show" class="box">フェードする段落</p>
</Transition>
</template>

<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

name を付けるとクラスの接頭辞がその名前になります。書かなければ v-enter-from のように v が付きます。enter / leave のクラスは 6 つです (appear を使うときはさらに 3 つ増えます)。

クラス付く時点外れる時点
*-enter-from挿入の直前挿入の 1 フレーム後
*-enter-active挿入の直前トランジションの終了時
*-enter-to挿入の 1 フレーム後トランジションの終了時
*-leave-fromleave が始まった直後1 フレーム後
*-leave-activeleave が始まった直後トランジションの終了時
*-leave-toleave の 1 フレーム後トランジションの終了時

*-active だけが最初から最後まで付いているので、transition の指定はここに書きます。

スロットに置けるのは要素かコンポーネント 1 つだけです。コンポーネントを置く場合、そのコンポーネント自身も単一のルート要素でなければなりません。

「1 フレーム後」の実体

実測すると、クラスの状態はこう動きます。

時点付いているクラス
挿入の直前fade-enter-from + fade-enter-active
requestAnimationFrame を 2 回跨いだ後fade-enter-active + fade-enter-to
終了後どちらも無い

「1 フレーム後」は requestAnimationFrame二重ネストです。

function nextFrame(cb) {
requestAnimationFrame(() => {
requestAnimationFrame(cb)
})
}

1 回では足りないのは、enter-from を付けた状態がブラウザに認識される前に外してしまうとトランジションが始まらないためです。自分でスタイルを触るコードを混ぜるときは、この 2 フレームの間に何をするかで結果が変わります。

クラス名を直接指定する props (enter-from-class など) もあります。空白区切りで複数のクラスを書けます。ユーティリティクラス中心の CSS と組むときに使います。

<Transition
enter-from-class="opacity-0 scale-90"
enter-active-class="transition-all duration-300"
enter-to-class="opacity-100 scale-100"
>
<p v-if="show">本文</p>
</Transition>

終わりをどう判定しているか

Vue は要素の computed style から transitionanimation の delay + duration を読み、長い方を採ります。そのうえで transitionendanimationend を数えます。

timeout = Math.max(transitionTimeout, animationTimeout)
type = timeout > 0 ? (transitionTimeout > animationTimeout ? TRANSITION : ANIMATION) : null

引用したのは type を書かなかったときの分岐です。ここには癖があります。

  • どちらも 0 なら type が null になり、その場で終了扱いになります。CSS を書き忘れた <Transition> は何もせず通り抜けます
  • 比較が厳密な不等号なので、同じ長さなら animation が勝ちます

type を書いた場合はその種類の duration だけを見ます。type="transition" と書いて animation しか付けていなければ、上と同じく「0 なので即終了」になります。

数えるイベントの側にも条件があります。その要素自身から出たものだけを数え、カンマ区切りの duration の個数に達したら終わりです。子要素から上がってきたイベントは数に入りません。

これが入れ子の要素をアニメーションさせたときに問題になります。内側から上がってきたイベントは数に入りません。 だから外側自身の transition が終わった時点で終了扱いになり、内側に長い transition-delay を付けても待ってくれません。外側に何も宣言していなければ、そもそも 0 と判定されて即終了です。

外側の transition を内側より長くすれば待てますが、それは内側の時間を外側へ写す手作業です。duration を明示すれば上書きできます。

<Transition :duration="550">...</Transition>
<Transition :duration="{ enter: 500, leave: 800 }">...</Transition>

duration を渡すと、その方向についてはイベントを一切聞かなくなります。指定した時間の setTimeout だけで終了を決めるので、実際のアニメーションが長引いてもクラスは外れます。オブジェクトで片方だけ書くと、書かなかった方は自動判定へ戻ります

transition と animation を同じ要素にかけていると自動判定が当てになりません。この場合だけ type で明示します。

<Transition type="animation">...</Transition>

JavaScript で書く

CSS で書けないもの (要素の実寸に依存する高さのアニメーションなど) は JavaScript のフックで書きます。enter / leave のフックは 8 つあります (appear 用に 4 つ加わって全部で 12 個です)。公式ガイドは leave-cancelledv-show 限定と説明しています。

JavaScript だけで書くなら :css="false" を添えます。クラスの付け外しと自動判定を丸ごと止めるので、CSS が事故で干渉することもなくなります。

components/HeightBox.vue
<script setup lang="ts">
import { ref } from 'vue'

const show = ref(true)

function onEnter(el: Element, done: () => void) {
const box = el as HTMLElement
box.style.height = '0'
box.animate([{ height: '0' }, { height: `${box.scrollHeight}px` }], { duration: 300 })
.finished.then(() => {
box.style.height = ''
done()
})
}

function onLeave(el: Element, done: () => void) {
const box = el as HTMLElement
box.animate([{ height: `${box.scrollHeight}px` }, { height: '0' }], { duration: 300 })
.finished.then(done)
}
</script>

<template>
<Transition :css="false" @enter="onEnter" @leave="onLeave">
<div v-if="show" class="box">高さが変わる中身</div>
</Transition>
</template>

ここで押さえておくことが 1 つあります。done を待つかどうかは、フックが宣言した引数の数で決まります。

if (hook.length <= 1) {
done()
}

引数を 2 つ書けば、こちらが done() を呼ぶまで待ちます。1 つ以下だと待ちません。その先の挙動は css の設定で分かれます。

css引数 1 つ以下引数 2 つ以上
falseVue が即座に done() を呼ぶdone() を呼ぶまで待つ
既定 (有効)CSS の終わりまで待つdone() を呼ぶまで待つ (CSS の終わりも見ない)

つまり :css="false"(el) => { el.animate(...) } と書くとアニメーションの完了を待たずに次へ進み、どちらの設定でも (el, done) => { ... }done() を呼び忘れるといつまでも終わりません。判定に使われるのは関数の length です。length既定値を持つ引数とそれ以降を数えないので、(el, done = noop) => { ... } は 1 引数として扱われます。宣言だけ見て「done を受け取っている」と思っていると、待たずに先へ進みます。

初回描画にもかける

appear を付けると、最初に描かれるときもトランジションが走ります。

<Transition appear name="fade">
<p>最初から居る段落</p>
</Transition>

クラスもフックも、appear 用を書いていない分だけ enter 用へ落ちますappear-from-class を書かなければ *-enter-from が使われ、@after-appear を書かなければ @after-enter が呼ばれます。

裏返すと、書いた分は enter 用を置き換えます@before-appear@appear を書くと、初回描画ではそちらだけが呼ばれ、@before-enter@enter は呼ばれません。両方に処理を置いて両方走るつもりでいると、初回だけ片方が抜けます。

2 つの要素を入れ替える

v-if / v-else で 2 つの要素を切り替えると、既定では両方が同時に DOM にいます。重なりを避けるために position: absolute を使うのが定石ですが、それが取れない場合は mode で順番を付けられます。

<Transition mode="out-in" name="fade">
<p v-if="saved" class="saved">保存済み</p>
<p v-else class="editing">編集中</p>
</Transition>

out-in は古い方が抜けきってから新しい方を入れます。逆の in-out もありますが、公式ガイドも "much less frequently used" としています。

name は動的にできます。状態に応じてアニメーションを切り替えたいときに使います。

<Transition :name="direction === 'next' ? 'slide-left' : 'slide-right'" mode="out-in">
<p :key="page">{{ page }} ページ目</p>
</Transition>

key を振っているのは、同じ要素のまま中身だけ変わってもトランジションが起きないためです。key を変えると要素の入れ替えとして扱われます — この章の冒頭で挙げた 4 つのきっかけのうちの 1 つです。

リストの出入りと並べ替え

<TransitionGroup> はリストを扱います。<Transition> との主な違いはこの 3 つです。

  • 既定ではラッパー要素を描きませんtag を渡すとその要素で包みます
  • 中の要素は常に key が必須です。無いと <TransitionGroup> children must be keyed. の警告が出ますが、これは開発ビルドだけです。本番では何も言わずに並べ替えの追従が狂います
  • mode が使えません。排他的な 2 つを入れ替えるわけではないためです
components/SlideList.vue
<script setup lang="ts">
import { ref } from 'vue'

interface Item {
id: number
name: string
}

let nextId = 3
const items = ref<Item[]>([
{ id: 1, name: 'りんご' },
{ id: 2, name: 'ばなな' },
])

function add() {
nextId += 1
items.value.push({ id: nextId, name: `果物 ${nextId}` })
}

function remove(id: number) {
items.value = items.value.filter((item) => item.id !== id)
}
</script>

<template>
<button type="button" @click="add">追加</button>

<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id" @click="remove(item.id)">
{{ item.name }}
</li>
</TransitionGroup>
</template>

<style scoped>
.list-enter-active,
.list-leave-active,
.list-move {
transition: all 0.3s ease;
}

.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}

.list-leave-active {
position: absolute;
}
</style>

*-move が並べ替えに使われるクラスです。仕組みは FLIP で、更新後に旧位置と新位置の差を transform で打ち消してから、それを外してアニメーションさせます。

このクラスが transform の transition を宣言していないと、並べ替えのアニメーションは一切走りません。 判定のしかたも独特です。Vue は要素のクローンを作り、*-move を付けて display: none で一時的に DOM へ挿し、computed style の transition-propertytransformall が入っているかを見ます。.list-move { transition: transform 0.3s } を書き忘れると、この時点で全部が省かれます。

判定は前節の自動判定を type 抜きで通します。transition として選ばれることも条件なので、クローンに当たる animation が同じ長さ以上あると animation が勝ち、transform の transition を書いていても FLIP は省かれます。*-move に併記した場合だけでなく、要素そのものに付けた animation でも起こります。<TransitionGroup type="transition"> と書いてもこの判定には効きません。

.list-leave-active { position: absolute } を添えるのも定石です。抜けていく要素を通常フローから外さないと、残る要素の移動と重なって位置が飛びます。

他の組み込みと組むとき

<Transition><KeepAlive>13 章<Suspense>、Vue Router の <RouterView> と組むときは入れ子の順序が決まっています。公式ガイドの Suspense のページが示す形はこれです。

<RouterView v-slot="{ Component }">
<template v-if="Component">
<Transition mode="out-in">
<KeepAlive>
<Suspense>
<component :is="Component" />
<template #fallback>読み込み中</template>
</Suspense>
</KeepAlive>
</Transition>
</template>
</RouterView>

この形は実測でも動きます。測って分かった注意を 2 つ挙げます。

ライフサイクルフックは境界の解決より後に来ます。 初回は pendingfallbackresolveonMountedonActivated の順でした。Suspense がポストフラッシュの処理を解決まで遅らせるためです。

差し替えの間も前の中身が残ります。 13 章で見た Suspense の挙動 (差し替えでは前の中身を出したまま待つ) が、mode="out-in" を挟んでも変わりません。古い方を外すのは境界が resolve したときで、pending の間は触られないためです。実測した順序は pending → (新しい方の解決) → resolve → 古い方の onDeactivated → 新しい方の onMounted でした。この経路で fallback は出ません。差し替えのときに timeout を書いていないためです。

まとめ

  • <Transition> はアニメーションを書きません。決まったタイミングでクラスを付け外しし、JavaScript のフックを呼ぶだけです
  • 公式ガイドが挙げる出入りのきっかけは v-if / v-show / <component :is> の切り替え / key の変更の 4 つ。スロットに置けるのは要素かコンポーネント 1 つだけです
  • enter / leave のクラスは 6 つ。*-active だけが最初から最後まで付いているので transition はそこに書きます。name で接頭辞が変わり、書かなければ v です
  • 「1 フレーム後」の実体は requestAnimationFrame の二重ネストです。1 回では enter-from の状態がブラウザに認識される前に外れてしまいます
  • クラス名の props は空白区切りで複数書けます
  • 終わりの判定は computed style の delay + duration です。type を書かなければ長い方が採られ、どちらも 0 なら何もせず通り抜け同じ長さなら animation が勝ちます。数えるのはその要素自身から出たイベントだけで、カンマ区切りの個数に達するまで待ちます。内側の要素から上がってきたイベントは数に入りません
  • duration を渡すと、その方向についてはイベントを一切聞かず setTimeout だけで終了を決めます。オブジェクトで片方だけ書くと、書かなかった方は自動判定へ戻ります
  • transition と animation を同じ要素にかけたときは type で明示します。ただし TransitionGroup*-move の判定には効きません
  • enter / leave の JS フックは 8 つ (appear 用が 4 つ加わって全部で 12 個)。done を待つかどうかはフックが宣言した引数の数で決まります。2 つ以上なら呼ばれるまで待ち、1 つ以下なら :css="false" では即座に done()、CSS が有効なら CSS の終わりまで待ちます。length は既定値付きの引数を数えないので (el, done = noop) => {} は 1 引数扱いです
  • :css="false" はクラスの付け外しと自動判定を丸ごと止めます
  • appear はクラスもフックも書いていない分だけ enter 側へ落ちます。書いた分は enter 用を置き換えるので、@appear を書くと初回描画で @enter は呼ばれません
  • mode を書かなければ新旧が同時に DOM にいます。out-in は古い方が抜けきってから新しい方を入れます。name は動的にできます。中身だけが変わる要素は key を振らないとトランジションが起きません
  • <TransitionGroup>ラッパーを描かず (tag で指定)、key が必須で (警告は開発ビルドだけ)、mode が使えません
  • 並べ替えは *-move の FLIP です。このクラスが transform の transition を宣言していて、かつ type 抜きの自動判定で transition が選ばれないと一切走りません。判定はクローンを一時的に DOM へ挿して computed style を読む形で行われます
  • 他の組み込みと組む順序は RouterView > Transition > KeepAlive > Suspenseライフサイクルは境界の解決より後に来ます。差し替えの間は mode="out-in" を挟んでも前の中身が残り、古い方が外れるのは境界が resolve したときです
関連リファレンス

次に読む