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
<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 特效。

進入特效
離開特效
查看範例原始碼
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>

原理

  1. 攔截 Transition 事件
  2. 建立一個覆蓋在元素上的 canvas,使用 CSS Anchor 定位
  3. 使用 snapDOM 將 DOM 放到 canvas
  4. 實現 enterleave 動畫邏輯

原始碼

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.49.3