Skip to content
歡迎來票選你最喜歡的元件! 也可以告訴我任何你想說的話喔!(*´∀`)~♥

VFX 轉場 transition

曾經使用 SVG Filter 實作,不只會占用 stylefilter 屬性,還受限於 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 元件相同。

查看範例原始碼
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="$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 特效。

查看範例原始碼
vue
<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>

圖片輪播

可以製作獨特的圖片輪播。

查看範例原始碼
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()
}

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>

粉碎內容

滾動!粉碎!

歪歪,主機承受不住流量了 乁( ◔ ௰◔)「
查看範例原始碼
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>

原理

整體概念就像電影替身一樣,危險動作(酷炫特效)交給 Canvas 替身演員(Actor)處理,本人(DOM)只要在動作完成時負責站好就好 ( •̀ ω •́ )✧

具體流程如下:

攔截 Transition 事件

元件使用 Vue 內建的 <transition> 搭配 :css="false",完全由 JS 接管 enterleave 等 Hook。

動態載入 Actor

透過 import.meta.glob('./actors/*.vue') 動態載入所有 Actor 元件, 依據 enterParams.nameleaveParams.name 決定要用哪個特效。

每個 Actor 都是一個獨立的 Canvas 元件,實作 initenterleave 三個方法。

替身定位

Actor Canvas 使用 CSS Anchor Positioning 定位在原始元素上方,並放大 3 倍避免動畫溢出時被裁切。

搭配 v-intersection-observer 偵測可見性,不在畫面內時隱藏 Actor,節省效能 (≧∀≦)ゞ

Enter 流程

  1. before-enter 隱藏原始 DOM(opacity: 0),加上 CSS Anchor Name
  2. Actor 的 init 方法將 DOM 複製一份, 透過 snapdom.toCanvas() 擷取成 Canvas 快照
  3. Actor 依據快照建立動畫資料(例如 shatter 會切出大量三角形碎片)
  4. 使用 anime.js 驅動 progressRate(0 → 1), 搭配 requestAnimationFrame 每幀呼叫 draw 繪製動畫
  5. 動畫結束後恢復原始 DOM(opacity: 1

Leave 流程

和 Enter 類似,但方向相反。

  1. before-leave 加上 CSS Anchor Name
  2. Actor init 擷取 DOM 快照
  3. 隱藏原始 DOM,由 Actor 從完整畫面(progressRate 1 → 0)播放消散動畫
  4. 動畫結束後呼叫 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;
}>()

v0.60.0