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 內建的 Transition 元件相同。
查看範例原始碼
<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="$t('show')"
class="p-4"
/>
</div>
<div class="flex justify-center">
<transition-vfx>
<div
v-if="visible"
class="card rounded p-6"
>
{{ $t('hello') }}
</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(true)
</script>
<style scoped lang="sass">
.card
background-color: light-dark(#edf0f2, #383e45)
</style>進入與離開
可以分別指定 enter 與 leave 特效。

查看範例原始碼
<template>
<div class="w-full flex flex-col gap-4">
<div class="grid grid-cols-4 items-center gap-2 border rounded p-4">
<select-stepper
v-model="enterName"
:label="t('enterEffect')"
:options="options"
class="col-span-2"
/>
<select-stepper
v-model="leaveName"
:label="t('leaveEffect')"
:options="options"
class="col-span-2"
/>
<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="t('show')"
class="w-full cursor-pointer p-4"
:class="{ 'pointer-events-none': isTransitioning }"
/>
</div>
</div>
<div
class="min-h-[60vmax] flex items-center justify-center md:min-h-[50vh]"
:class="{ 'pointer-events-none': isTransitioning }"
>
<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">
{{ t('codfishName') }}
</div>
<base-input
v-model="text"
class="text-center md:w-[22rem]"
:class="{
'font-bold': styles.bold,
'italic': styles.italic,
}"
/>
<div class="flex gap-2">
<base-checkbox
v-model="styles.bold"
:label="t('bold')"
/>
<base-checkbox
v-model="styles.italic"
:label="t('italic')"
/>
</div>
</div>
</transition-vfx>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import BaseInput from '../../base-input.vue'
import SelectStepper from '../../select-stepper.vue'
import TransitionVfx from '../transition-vfx.vue'
import { TransitionName } from '../type'
const { t } = useI18n()
const text = ref(t('inputPlaceholder'))
const styles = ref({
bold: false,
italic: false,
})
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>圖片輪播
可以製作獨特的圖片輪播。

查看範例原始碼
<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()
}
function preloadImage(src: string): Promise<void> {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => resolve()
img.onerror = () => resolve()
img.src = src
})
}
async function next() {
// 先觸發 leave(快照抓到舊圖片),不要同時改 image URL
isStarted.value = true
await until(isLeaving).toBe(true)
await until(isLeaving).toBe(false)
// leave 結束後再切換圖片,並確保新圖已載入
imageCycle.next()
await preloadImage(image.value)
isStarted.value = false
changeTransition()
}
async function prev() {
isStarted.value = true
await until(isLeaving).toBe(true)
await until(isLeaving).toBe(false)
imageCycle.prev()
await preloadImage(image.value)
isStarted.value = false
changeTransition()
}
</script>粉碎內容
滾動!粉碎!
查看範例原始碼
<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>原理
整體概念就像電影替身一樣,危險動作(酷炫特效)交給 Canvas 替身演員(Actor)處理,本人(DOM)只要在動作完成時負責站好就好 ( •̀ ω •́ )✧
具體流程如下:
攔截 Transition 事件
元件使用 Vue 內建的 <transition> 搭配 :css="false",完全由 JS 接管 enter、leave 等 Hook。
動態載入 Actor
透過 import.meta.glob('./actors/*.vue') 動態載入所有 Actor 元件, 依據 enterParams.name 與 leaveParams.name 決定要用哪個特效。
每個 Actor 都是一個獨立的 Canvas 元件,實作 init、enter、leave 三個方法。
替身定位
Actor Canvas 使用 CSS Anchor Positioning 定位在原始元素上方,並放大 3 倍避免動畫溢出時被裁切。
搭配 v-intersection-observer 偵測可見性,不在畫面內時隱藏 Actor,節省效能 (≧∀≦)ゞ
Enter 流程
before-enter隱藏原始 DOM(opacity: 0),加上 CSS Anchor Name- Actor 的
init方法將 DOM 複製一份, 透過snapdom.toCanvas()擷取成 Canvas 快照 - Actor 依據快照建立動畫資料(例如 shatter 會切出大量三角形碎片)
- 使用 anime.js 驅動
progressRate(0 → 1), 搭配requestAnimationFrame每幀呼叫draw繪製動畫 - 動畫結束後恢復原始 DOM(
opacity: 1)
Leave 流程
和 Enter 類似,但方向相反。
before-leave加上 CSS Anchor Name- Actor
init擷取 DOM 快照 - 隱藏原始 DOM,由 Actor 從完整畫面(
progressRate1 → 0)播放消散動畫 - 動畫結束後呼叫
done(),完成離場
Canvas 尺寸處理
useActor Composable 會依據 Device Pixel Ratio 計算 Canvas 實際像素大小, 並限制不超過 4096px,避免超出行動裝置 GPU 紋理上限 (´・ω・`)
原始碼
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;
}>()