VFX 轉場 transition
曾經使用 SVG Filter 實作,不只會占用 style
之 filter
屬性,還受限於 SVG 支援度問題等等,總之就是限制相當多 ( ˘•ω•˘ )
多虧 snapDOM
,可以輕鬆將 DOM 放到 Canvas 中,變出各種酷炫的效果了!ヾ(◍'౪`◍)ノ゙
技術關鍵字
名稱 | 描述 |
---|---|
Canvas 2D API | 基礎的 2D 繪圖 API,可以高效繪製比 DOM 更複雜的圖形 |
Canvas Shader | 使用 GLSL 開發,直接在 GPU 上執行,比 Canvas 2D API 更快,但也更難 |
DOM to Image | 將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現 |
使用範例
基本用法
用法與 Vue 內建的 Transition 元件相同。
查看範例原始碼
vue
<template>
<div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl p-6">
<div class="flex flex-col gap-4 border rounded">
<base-checkbox
v-model="visible"
label="顯示"
class="p-4"
/>
</div>
<div class="flex justify-center">
<transition-vfx>
<div
v-if="visible"
class="card rounded p-6"
>
安安
</div>
</transition-vfx>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import BaseCheckbox from '../../base-checkbox.vue'
import TransitionVfx from '../transition-vfx.vue'
const visible = ref(false)
</script>
<style scoped lang="sass">
.card
background-color: light-dark(#edf0f2, #383e45)
</style>
進入與離開
可以分別指定 enter 與 leave 特效。
進入特效
離開特效

鱈魚 Codfish
困擾買不到 IP69K 等級的防水電腦 (╥ω╥`)
查看範例原始碼
vue
<template>
<div class="w-full flex flex-col gap-4">
<div class="grid grid-cols-6 items-center gap-2 border rounded p-4">
<div class="col-span-2 md:col-span-1">
進入特效
</div>
<div class="col-span-4 border rounded md:col-span-2">
<select
v-model="enterName"
class="w-full p-2"
>
<option
v-for="option in options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
</div>
<div class="col-span-2 md:col-span-1">
離開特效
</div>
<div class="col-span-4 border rounded md:col-span-2">
<select
v-model="leaveName"
class="w-full p-2"
>
<option
v-for="option in options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
</div>
<div
class="col-span-6 border rounded-lg duration-300"
:class="{
'cursor-not-allowed opacity-30': isTransitioning,
'cursor-pointer': !isTransitioning,
}"
>
<base-checkbox
v-model="visible"
label="顯示"
class="w-full cursor-pointer p-4"
:class="{ 'pointer-events-none': isTransitioning }"
/>
</div>
</div>
<div
class="h-[50vh] flex cursor-pointer items-center justify-center"
:class="{ 'pointer-events-none': isTransitioning }"
@click="visible = !visible"
>
<transition-vfx
:enter-params="params.enter"
:leave-params="params.leave"
@enter="isTransitioning = true"
@leave="isTransitioning = true"
@after-enter="isTransitioning = false"
@after-leave="isTransitioning = false"
>
<div
v-if="visible"
class="card flex flex-col items-center gap-4 border rounded-xl p-6"
>
<img
src="/low/profile.webp"
class="mb-4 h-[180px] w-[180px] overflow-hidden border-4 border-white rounded-full shadow-xl"
>
<div class="text-xl font-bold">
鱈魚 Codfish
</div>
<div>
困擾買不到 IP69K 等級的防水電腦 (╥ω╥`)
</div>
</div>
</transition-vfx>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import BaseCheckbox from '../../base-checkbox.vue'
import TransitionVfx from '../transition-vfx.vue'
import { TransitionName } from '../type'
const isTransitioning = ref(false)
const visible = ref(true)
const enterName = ref<`${TransitionName}`>('shatter')
const leaveName = ref<`${TransitionName}`>('shatter')
const params = computed(() => ({
enter: { name: enterName.value },
leave: { name: leaveName.value },
}))
const options = Object.values(TransitionName)
</script>
<style scoped lang="sass">
.card
background-color: light-dark(#edf0f2, #383e45)
</style>
圖片輪播
可以製作獨特的圖片輪播。

查看範例原始碼
vue
<template>
<div class="w-full flex flex-col gap-4">
<div class="relative h-[50vh]">
<transition-vfx
:enter-params="transitionName"
:leave-params="transitionName"
@enter="isEntering = true"
@leave="isLeaving = true"
@after-enter="isEntering = false"
@after-leave="isLeaving = false"
>
<div
v-if="!isStarted"
class="absolute left-0 top-0 h-full w-full overflow-hidden rounded-lg"
>
<img
:src="image"
class="h-full w-full object-cover"
>
</div>
</transition-vfx>
</div>
<div
class="flex gap-4 duration-300"
:class="{ ' cursor-not-allowed opacity-30': isTransitioning }"
>
<base-btn
label="上一個"
class="flex-1"
:class="{ 'pointer-events-none': isTransitioning }"
@click="prev()"
/>
<base-btn
label="下一個"
class="flex-1"
:class="{ 'pointer-events-none': isTransitioning }"
@click="next()"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { until, useCycleList } from '@vueuse/core'
import { computed, ref } from 'vue'
import BaseBtn from '../../base-btn.vue'
import TransitionVfx from '../transition-vfx.vue'
import { TransitionName } from '../type'
const transitionCycle = useCycleList(Object.values(TransitionName))
const transitionName = computed(() => ({ name: transitionCycle.state.value }))
const imageList = [
'/low/painting-codfish-bakery.webp',
'/low/painting-codfish-rain.webp',
'/low/photography-fireworks.webp',
'/low/photography-morning-light-of-rice.webp',
'/low/photography-big-stupid-bird.webp',
'/low/photography-ears-of-rice.webp',
'/low/photography-gaomei-windmill.webp',
'/low/photography-spider-at-night.webp',
'/low/photography-street-cat.webp',
]
const imageCycle = useCycleList(imageList)
const image = computed(() => imageCycle.state.value)
const isStarted = ref(false)
const isEntering = ref(false)
const isLeaving = ref(false)
const isTransitioning = computed(() => isEntering.value || isLeaving.value)
async function changeTransition() {
await until(isEntering).toBe(true)
await until(isEntering).toBe(false)
transitionCycle.next()
}
async function next() {
isStarted.value = true
imageCycle.next()
await until(isLeaving).toBe(true)
await until(isLeaving).toBe(false)
isStarted.value = false
changeTransition()
}
async function prev() {
isStarted.value = true
imageCycle.prev()
await until(isEntering).toBe(true)
await until(isEntering).toBe(false)
isStarted.value = false
changeTransition()
}
</script>
粉碎內容
滾動!粉碎!
歪歪,主機承受不住流量了 乁( ◔ ௰◔)「
查看範例原始碼
vue
<template>
<div class="h-[70vh] w-full overflow-auto border border-gray-200 rounded-xl p-2">
<div class="h-[70vh] flex items-center justify-center text-xl text-gray-400">
{{ !allGone ? '滾動查看下方更多精彩資訊 ◝( •ω• )◟' : '歪歪,主機承受不住流量了 乁( ◔ ௰◔)「' }}
</div>
<div class="w-full flex flex-col gap-8 p-4">
<div v-intersection-observer="([entry]) => handleIntersection(0, entry)">
<transition-vfx :duration="2000">
<div
v-if="visibleList[0]"
class="text-center text-6xl text-gray-600 font-extrabold"
>
歡迎來到 Cod 工作室
</div>
</transition-vfx>
</div>
<div v-intersection-observer="([entry]) => handleIntersection(1, entry)">
<transition-vfx :duration="2000">
<div
v-if="visibleList[1]"
class="mt-6 text-center text-xl text-gray-500"
>
專注於提供 Fish 解決方案,從 XX 設計、YY 互動到 ZZ 視覺,助你破壞品牌形象。
</div>
</transition-vfx>
</div>
<div v-intersection-observer="([entry]) => handleIntersection(2, entry)">
<transition-vfx :duration="2000">
<div
v-if="visibleList[2]"
class="my-12 w-full rounded-xl bg-gray-100 py-5 text-center text-gray-800"
>
需求一出手,回應不落空;使命必達、如影隨形
</div>
</transition-vfx>
</div>
<div class="flex flex-col items-center gap-4">
<div v-intersection-observer="([entry]) => handleIntersection(3, entry)">
<transition-vfx :duration="2000">
<button
v-if="visibleList[3]"
class="rounded-full bg-purple-500 px-10 py-6 text-2xl text-white shadow-lg"
>
即刻體驗
</button>
</transition-vfx>
</div>
<div v-intersection-observer="([entry]) => handleIntersection(4, entry)">
<transition-vfx :duration="2000">
<span
v-if="visibleList[4]"
class="mt-4 text-center text-base text-gray-400"
>
讓你的品牌立即炎上!
</span>
</transition-vfx>
</div>
</div>
<div class="flex flex-col gap-4">
<div v-intersection-observer="([entry]) => handleIntersection(5, entry)">
<transition-vfx :duration="2000">
<div
v-if="visibleList[5]"
class="rounded p-6"
>
<div class="mb-2 text-lg font-bold">
專業團隊
</div>
<div class="text-gray-600">
我們的團隊由經驗豐富且充滿熱情的專業人士組成,致力於為客戶提供最佳解決方案。
</div>
</div>
</transition-vfx>
</div>
<div
v-for="(member, i) in teamMembers"
:key="member.name"
v-intersection-observer="([entry]) => handleIntersection(6 + i, entry)"
>
<transition-vfx :duration="2000">
<div
v-if="visibleList[6 + i]"
class="card w-fll flex items-center gap-4 border rounded-xl p-6"
>
<img
:src="member.img"
class="mb-4 aspect-square w-[100px] shrink-0 overflow-hidden border-4 border-white rounded-full shadow-xl"
>
<div class="flex flex-col gap-4">
<div class="text-xl font-bold">
{{ member.name }}
</div>
<div>
{{ member.desc }}
</div>
</div>
</div>
</transition-vfx>
</div>
</div>
<div v-intersection-observer="([entry]) => handleIntersection(9, entry)">
<transition-vfx :duration="2000">
<div
v-if="visibleList[9]"
class="mt-12 text-center text-sm text-gray-400"
>
© 2025 Cod 工作室。版權所有,不得轉載。
</div>
</transition-vfx>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { vIntersectionObserver } from '@vueuse/components'
import { promiseTimeout } from '@vueuse/core'
import { computed, ref } from 'vue'
import TransitionVfx from '../transition-vfx.vue'
const visibleList = ref<boolean[]>([])
const allGone = computed(() => visibleList.value.every((value) => !value))
async function handleIntersection(index: number, entry: IntersectionObserverEntry | undefined) {
if (visibleList.value[index] === undefined) {
visibleList.value[index] = true
}
const value = !entry?.isIntersecting && visibleList.value[index]
if (!value) {
await promiseTimeout(600)
visibleList.value[index] = value
}
}
const teamMembers = [
{ name: '鱈魚', desc: '困擾買不到 IP69K 等級的防水電腦 (╥ω╥`)', img: '/low/profile.webp' },
{ name: '玻璃魚', desc: '善成用舌頭清潔魚缸的玻璃 (๑•̀ㅂ•́)و✧', img: '/low/profile-2.webp' },
{ name: '野餐魚', desc: '熱愛戶外活動的魚,總是帶著便當盒 ( ´ ▽ ` )ノ', img: '/low/profile-3.webp' },
]
</script>
<style scoped lang="sass">
.card
background-color: light-dark(#edf0f2, #383e45)
</style>
原理
- 攔截
Transition
事件 - 建立一個覆蓋在元素上的 canvas,使用 CSS Anchor 定位
- 使用
snapDOM
將 DOM 放到 canvas - 實現
enter
、leave
動畫邏輯
原始碼
API
Props
interface Props {
appear?: boolean;
enterParams?: TransitionParams;
leaveParams?: TransitionParams;
duration?: number;
}
Emits
const emit = defineEmits<{
(e: 'enter'): void;
(e: 'afterEnter'): void;
(e: 'leave'): void;
(e: 'afterLeave'): void;
}>()
Slots
const slots = defineSlots<{
default?: () => unknown;
}>()