每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。
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 | 內建元件,可以處理單個項目的進出動畫 |
| Anime.js | 輕量級 JavaScript 動畫函式庫 |
| JS 動畫 | 基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等 |
| CSS Anchor Positioning | CSS 新功能,允許元素相對於其他元素(錨點)進行定位 |
用法與 Vue 內建的 Transition 元件相同。
<template> <div class="w-full flex flex-col gap-4 example-wrap"> <div class="example-ctrl flex flex-col gap-4"> <base-checkbox v-model="visible" :label="$t('show')" /> </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="example-ctrl grid grid-cols-4 items-center gap-2"> <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" alt="" 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" alt="" 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 example-wrap p-2"> <div class="h-[70vh] flex items-center justify-center text-xl text-gray-400 dark:text-gray-500"> {{ !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 dark:text-gray-300 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 dark:text-gray-400" > 專注於提供 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 dark:bg-gray-800 py-5 text-center text-gray-800 dark:text-gray-200" > 需求一出手,回應不落空;使命必達、如影隨形 </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 dark:text-gray-500" > 讓你的品牌立即炎上! </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 dark:text-gray-300"> 我們的團隊由經驗豐富且充滿熱情的專業人士組成,致力於為客戶提供最佳解決方案。 </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" :alt="member.name" 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 dark:text-gray-500" > © 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 事件
Section titled “攔截 Transition 事件”元件使用 Vue 內建的 <transition> 搭配 :css="false",完全由 JS 接管 enter、leave 等 Hook。
動態載入 Actor
Section titled “動態載入 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 流程
Section titled “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 流程
Section titled “Leave 流程”和 Enter 類似,但方向相反。
before-leave加上 CSS Anchor Name- Actor
init擷取 DOM 快照 - 隱藏原始 DOM,由 Actor 從完整畫面(
progressRate1 → 0)播放消散動畫 - 動畫結束後呼叫
done(),完成離場
Canvas 尺寸處理
Section titled “Canvas 尺寸處理”useActor Composable 會依據 Device Pixel Ratio
計算 Canvas 實際像素大小,
並限制不超過 4096px,避免超出行動裝置 GPU 紋理上限
(´・ω・`)
interface Props { appear?: boolean; enterParams?: TransitionParams; leaveParams?: TransitionParams; duration?: number;}const emit = defineEmits<{ (e: 'enter'): void; (e: 'afterEnter'): void; (e: 'leave'): void; (e: 'afterLeave'): void;}>()const slots = defineSlots<{ default?: () => unknown;}>()