Skip to content

多邊形轉場 transition

Motion Graphic 風格的 Transition 元件。

甚麼是 Motion Graphic 轉場效果?可以看看這個影片:

Motion Graphic 轉場在影片製作其實很常見,不過網頁中就沒那麼常見了。

所以我就嘗試把效果搬上網頁了!ˋ( ° ▽、° )

雖然目前稍微有一點點瑕疵,不過基本功能與效果都已經實現,剩下的改天再來慢慢改進吧。(。・∀・)ノ

使用範例

基本用法

基於 Vue 內建的 Transition 元件,不過目前連續切換會壞掉,請先手下留情。 (〃` 3′〃)

即使新舊元素尺寸不同也可以用呦!ˋ( ° ▽、° )

鱈魚
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col items-start gap-4 border border-gray-300 p-6">
    <base-btn
      class="w-full"
      label="更換"
      @click="change()"
    />

    <div class="w-full flex flex-col gap-4 overflow-hidden border rounded bg-slate-100 p-6">
      <div class="flex justify-center">
        <transition-shape
          :type="imgTransition"
          @after-transition="handleReady"
        >
          <img
            :key="index"
            :src="profile"
            class="h-60 w-60 border-[0.5rem] border-white rounded-full object-cover shadow-md"
          >
        </transition-shape>
      </div>

      <div class="flex flex-col items-center justify-center gap-2 text-2xl font-bold">
        鱈魚
        <transition-shape
          :type="fishTransition"
          @after-transition="handleReady"
        >
          <div
            :key="index"
            class="p-2 px-4 text-3xl"
          >
            {{ fish }}
          </div>
        </transition-shape>
      </div>

      <transition-shape
        appear
        :type="textTransition"
        @after-transition="handleReady"
      >
        <div
          :key="index"
          class="p-8"
        >
          {{ introduction }}
        </div>
      </transition-shape>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { TransitionType } from '../type'
import { debounce } from 'lodash-es'
import { hasAtLeast, piped, reverse } from 'remeda'
import { computed, ref } from 'vue'
import BaseBtn from '../../base-btn.vue'
import TransitionShape from '../transition-shape.vue'

const index = ref(0)

const fishList = [
  '🐟',
  '🐋🐋',
  '🐠',
  '🐡🐡',
]
const fish = computed(() => fishList[index.value % fishList.length])

const profileList = [
  '/profile.webp',
  '/profile-2.webp',
  '/profile-3.webp',
]
const profile = computed(() => profileList[index.value % profileList.length])

const introductionList = [
  `一隻熱愛程式的魚,但是沒有手指可以打鍵盤,更買不到能在水裡用的電腦。('◉◞⊖◟◉\` )`,
  '最擅長的球類是地瓜球,一打十輕輕鬆鬆。( •̀ ω •́ )✧',
  `不知道是不是在水裡躺平躺久了,最近喝水也會胖。\n_(:3」ㄥ)_`,
]
const introduction = computed(() => introductionList[index.value % introductionList.length])

const isReady = ref(false)
const handleReady = debounce(() => {
  isReady.value = true
}, 500)

function change() {
  if (!isReady.value)
    return
  isReady.value = false
  index.value++
}

const colors: [string, ...string[]] = ['#012030', '#13678A', '#45C4B0', '#9AEBA3', '#DAFDBA']
const reverseColors = piped(
  reverse<string[]>(),
  (result) => {
    if (!hasAtLeast(result, 1)) {
      throw new Error('At least one color is required')
    }
    return result
  },
)

const baseOption = {
  duration: 800,
  delay: 100,
}

const imgTransition: TransitionType = {
  name: 'round',
  enter: {
    action: 'spread-scale',
    easing: 'easeOutQuart',
    ...baseOption,
  },
  leave: {
    action: 'scale',
    easing: 'easeInQuart',
    ...baseOption,
  },
  colors,
}

const fishTransition: TransitionType = {
  name: 'rect',
  enter: {
    action: 'slide-down',
    easing: 'easeOutQuart',
    ...baseOption,
  },
  leave: {
    action: 'slide-right',
    easing: 'easeInQuart',
    ...baseOption,
  },
  colors: reverseColors(colors),
}

const textTransition: TransitionType = {
  name: 'fence',
  enter: {
    action: 'spread-left',
    easing: 'easeOutQuart',
    ...baseOption,
  },
  leave: {
    action: 'scale-x',
    easing: 'easeInQuart',
    ...baseOption,
  },
  colors,
}
</script>

轉場參數

可以微調各類轉場參數。

顏色
進入 action
easing
delay (ms)
duration (ms)
離開 action
easing
delay (ms)
duration (ms)
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col items-start gap-4 border border-gray-300 p-6">
    <div class="w-full flex flex-col gap-2">
      <div class="w-full flex items-center gap-3 border p-1">
        <div class="flex-1 text-right">
          顏色
        </div>
        <div class="flex flex-1 gap-1 p-2">
          <input
            v-model="colors[0]"
            type="color"
            class="flex-1"
          >
          <input
            v-model="colors[1]"
            type="color"
            class="flex-1"
          >
          <input
            v-model="colors[2]"
            type="color"
            class="flex-1"
          >
        </div>
      </div>

      <!-- 進入 -->
      <div class="w-full flex flex-col items-center gap-1 border p-1">
        <div class="w-full flex items-center gap-3 p-1">
          <div class="flex-1 text-right">
            進入 action
          </div>
          <select
            v-model="rectAction.enter.action"
            class="flex-1 rounded bg-slate-200 p-2"
          >
            <option
              v-for="option in rectActionOptions"
              :key="option"
              :value="option"
            >
              {{ option }}
            </option>
          </select>
        </div>

        <div class="w-full flex items-center gap-3 p-1">
          <div class="flex-1 text-right">
            easing
          </div>
          <select
            v-model="rectAction.enter.easing"
            class="flex-1 rounded bg-slate-200 p-2"
          >
            <option
              v-for="option in easingOptions"
              :key="option"
              :value="option"
            >
              {{ option }}
            </option>
          </select>
        </div>

        <div class="w-full flex items-center gap-3 p-1">
          <div class="flex-1 text-right">
            delay (ms)
          </div>
          <input
            v-model.number="rectAction.enter.delay"
            type="number"
            class="min-w-0 flex-1 rounded bg-slate-200 p-2"
          >
        </div>

        <div class="w-full flex items-center gap-3 p-1">
          <div class="flex-1 text-right">
            duration (ms)
          </div>
          <input
            v-model.number="rectAction.enter.duration"
            type="number"
            class="min-w-0 flex-1 rounded bg-slate-200 p-2"
          >
        </div>
      </div>

      <!-- 離開 -->
      <div class="w-full flex flex-col items-center gap-1 border p-1">
        <div class="w-full flex items-center gap-3 p-1">
          <div class="flex-1 text-right">
            離開 action
          </div>
          <select
            v-model="rectAction.leave.action"
            class="flex-1 rounded bg-slate-200 p-2"
          >
            <option
              v-for="option in rectActionOptions"
              :key="option"
              :value="option"
            >
              {{ option }}
            </option>
          </select>
        </div>

        <div class="w-full flex items-center gap-3 p-1">
          <div class="flex-1 text-right">
            easing
          </div>
          <select
            v-model="rectAction.leave.easing"
            class="flex-1 rounded bg-slate-200 p-2"
          >
            <option
              v-for="option in easingOptions"
              :key="option"
              :value="option"
            >
              {{ option }}
            </option>
          </select>
        </div>

        <div class="w-full flex items-center gap-3 p-1">
          <div class="flex-1 text-right">
            delay (ms)
          </div>
          <input
            v-model.number="rectAction.leave.delay"
            type="number"
            class="min-w-0 flex-1 rounded bg-slate-200 p-2"
          >
        </div>

        <div class="w-full flex items-center gap-3 p-1">
          <div class="flex-1 text-right">
            duration (ms)
          </div>
          <input
            v-model.number="rectAction.leave.duration"
            type="number"
            class="min-w-0 flex-1 rounded bg-slate-200 p-2"
          >
        </div>
      </div>

      <base-btn
        class="w-full"
        label="換魚"
        @click="changeFish()"
      />
    </div>

    <transition-shape :type="transitionType">
      <div
        :key="fishIndex"
        class="w-full text-center text-[10rem]"
      >
        {{ fishList[fishIndex] }}
      </div>
    </transition-shape>
  </div>
</template>

<script setup lang="ts">
import type anime from 'animejs'
import type { TransitionType } from '../type'
import { computed, ref } from 'vue'
import BaseBtn from '../../base-btn.vue'
import TransitionShape from '../transition-shape.vue'
import { RectAction } from '../type'

const fishIndex = ref(0)
const fishList = [
  '🐟',
  '🐋',
  '🐠',
  '🐡',
]
function changeFish() {
  fishIndex.value++
  fishIndex.value %= fishList.length
}

const colors = ref<
  [string, string, string]
>(['#7DC8FF', '#677580', '#374855'])
const rectAction = ref<
  Record<'enter' | 'leave', {
    action: `${RectAction}`;
    easing: anime.EasingOptions;
    delay: number;
    duration: number;
  }>
>({
  enter: {
    action: 'slide-right',
    easing: 'easeOutQuart',
    delay: 100,
    duration: 800,
  },
  leave: {
    action: 'slide-right',
    easing: 'easeInQuart',
    delay: 100,
    duration: 800,
  },
})
const rectActionOptions = Object.values(RectAction)

const timingList = ['In', 'Out', 'InOut']
const typeList = [
  'Quad',
  'Cubic',
  'Quart',
  'Quint',
  'Sine',
  'Expo',
  'Circ',
  'Back',
  'Elastic',
  'Bounce',
]
const easingOptions = [
  'linear',
  ...typeList.flatMap((type) =>
    timingList.map((timing) => `ease${timing}${type}`),
  ),
]

const transitionType = computed<TransitionType>(() => ({
  name: 'rect',
  enter: rectAction.value.enter,
  leave: rectAction.value.leave,
  colors: colors.value,
}))
</script>

Round 轉場

由圓形組成。

點擊任意魚,開始轉場。(≧∇≦)ノ

點擊展開
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-wrap justify-center gap-4 border border-gray-300 p-6">
    <div
      v-for="item in list"
      :key="item.key"
      class="w-full"
    >
      <transition-shape
        :type="item"
        @after-transition="handleInit()"
      >
        <div
          :key="fishIndex"
          class="w-full cursor-pointer py-6 text-center text-[5rem]"
          @click="handleClick()"
        >
          {{ fishList[fishIndex] }}
        </div>
      </transition-shape>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { TransitionType } from '../type'
import { useToggle } from '@vueuse/core'
import { debounce, throttle } from 'lodash-es'
import { hasAtLeast, map, pipe, shuffle } from 'remeda'
import { ref } from 'vue'
import TransitionShape from '../transition-shape.vue'
import { RoundBaseAction, RoundEnterAction } from '../type'

const fishIndex = ref(0)
const fishList = [
  '🐟',
  '🐋',
  '🐠',
  '🐡',
]

function changeFish() {
  fishIndex.value++
  fishIndex.value %= fishList.length
}

const [isReady, toggleReady] = useToggle(false)

const handleInit = debounce(() => {
  toggleReady(true)
}, 1000)

const handleClick = throttle(() => {
  if (!isReady.value) {
    handleClick.cancel()
    return
  }

  changeFish()
}, 4000, {
  leading: true,
  trailing: false,
})

const leaveActions = Object.values(RoundBaseAction)
const enterActions = [
  RoundEnterAction.SPREAD_LEFT,
  RoundEnterAction.SPREAD_SCALE,
  RoundBaseAction.SCALE,
  RoundBaseAction.SCALE_LB,
]

type Item = TransitionType & {
  key: string;
}
const list: Item[] = pipe(
  enterActions,
  map((action, i) => {
    const leaveAction = leaveActions[i % leaveActions.length]
    if (!leaveAction) {
      throw new Error('Leave action is required')
    }

    const colors = shuffle(['#27A4F2', '#44C1F2', '#85DEF2', '#DCEEF2', '#91E9F2'])
    if (!hasAtLeast(colors, 1)) {
      throw new Error('At least one color is required')
    }

    const result: Item = {
      key: action,
      name: 'round',
      enter: {
        action,
        duration: 900,
        delay: 200,
        easing: 'easeOutExpo',
      },
      leave: {
        action: leaveAction,
        duration: 900,
        delay: 200,
        easing: 'easeInExpo',
      },
      colors,
    }

    return result
  }),
)
</script>

Fence 轉場

像柵欄一般,由多個矩形組成。

點擊任意魚,開始轉場。(≧∇≦)ノ

點擊展開
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col justify-center gap-4 border border-gray-300 p-6">
    <div
      v-for="item in list"
      :key="item.key"
      class="w-full"
    >
      <transition-shape
        :type="item"
        @after-transition="handleInit()"
      >
        <div
          :key="fishIndex"
          class="w-full cursor-pointer py-6 text-center text-[6rem]"
          @click="handleClick()"
        >
          {{ fishList[fishIndex] }}
        </div>
      </transition-shape>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { TransitionType } from '../type'
import { useToggle } from '@vueuse/core'
import { debounce, shuffle, throttle } from 'lodash-es'
import { hasAtLeast, map, pipe, reverse } from 'remeda'
import { ref } from 'vue'
import TransitionShape from '../transition-shape.vue'
import { FenceAction } from '../type'

const fishIndex = ref(0)
const fishList = [
  '🐟',
  '🐋',
  '🐠',
  '🐡',
]

function changeFish() {
  fishIndex.value++
  fishIndex.value %= fishList.length
}

const [isReady, toggleReady] = useToggle(false)

const handleInit = debounce(() => {
  toggleReady(true)
}, 1000)

const handleClick = throttle(() => {
  if (!isReady.value) {
    handleClick.cancel()
    return
  }

  changeFish()
}, 3000, {
  leading: true,
  trailing: false,
})

const actions = Object.values(FenceAction)
// const actions = [FenceAction.SPREAD_RIGHT];
const reverseActions = pipe(actions, reverse())

type Item = TransitionType & {
  key: string;
}
const list: Item[] = pipe(
  actions,
  map((action, i) => {
    const targetAction = reverseActions[i] ?? action

    const colors = shuffle(['#27A4F2', '#44C1F2', '#85DEF2', '#DCEEF2', '#91E9F2'])
    if (!hasAtLeast(colors, 1)) {
      throw new Error('At least one color is required')
    }

    const result: Item = {
      key: action,
      name: 'fence',
      enter: {
        action,
        duration: 800,
        delay: 100,
        easing: 'easeOutQuart',
      },
      leave: {
        action: targetAction,
        duration: 800,
        delay: 100,
        easing: 'easeInQuart',
      },
      colors,
    }

    return result
  }),
)
</script>

Converging Rect 轉場

多個矩形向中心匯聚,可以設定傾斜角度。

點擊任意魚,開始轉場。(≧∇≦)ノ

點擊展開
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col justify-center gap-4 border border-gray-300 p-6">
    <div
      v-for="item in list"
      :key="item.key"
      class="w-full"
    >
      <transition-shape
        :type="item"
        @after-transition="handleInit()"
      >
        <div
          :key="fishIndex"
          class="w-full cursor-pointer py-5 text-center text-[5rem]"
          @click="handleClick()"
        >
          {{ fishList[fishIndex] }}
        </div>
      </transition-shape>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { TransitionType } from '../type'
import { useToggle } from '@vueuse/core'
import { debounce, throttle } from 'lodash-es'
import { map, pipe } from 'remeda'
import { ref } from 'vue'
import TransitionShape from '../transition-shape.vue'
import { ConvergingRectAction } from '../type'

const fishIndex = ref(0)
const fishList = [
  '🐟',
  '🐋',
  '🐠',
  '🐡',
]

function changeFish() {
  fishIndex.value++
  fishIndex.value %= fishList.length
}

const [isReady, toggleReady] = useToggle(false)

const handleInit = debounce(() => {
  toggleReady(true)
}, 1000)

const handleClick = throttle(() => {
  if (!isReady.value) {
    handleClick.cancel()
    return
  }

  changeFish()
}, 3000, {
  leading: true,
  trailing: false,
})

type Item = TransitionType & {
  key: string;
}
const list: Item[] = pipe(
  [0, 10, -30],
  map((angle) => {
    const action = ConvergingRectAction.SLIDE

    const result: Item = {
      key: action,
      name: 'converging-rect',
      enter: {
        action,
        angle,
        duration: 800,
        delay: 100,
        easing: 'easeOutExpo',
      },
      leave: {
        action,
        duration: 800,
        delay: 100,
        easing: 'easeInExpo',
      },
      colors: [
        '#27A4F2',
        '#44C1F2',
        '#85DEF2',
        '#DCEEF2',
        '#91E9F2',
      ],
    }

    return result
  }),
)
</script>

原理

基於 Vue 內建的 Transition 元件,使用 babylon.js 產生形狀遮罩,接著透過 Transition Event 控制動畫的開始與結束。

可以處理以下情境:

  • v-if 條件渲染
  • 變更元素 key

暫時不考慮 v-show 的情境。

定位問題

原本使用 fixed 配合頁面滾動事件來定位 canvas 位置,但是在快速滾動畫面時 sharp 還是會稍微偏移。

苦惱之際發現了 CSS Anchor Positioning API 這個神奇酷炫的東西,完美解決此問題!(๑•̀ㅂ•́)و✧

📚 CSS Anchor Positioning API

原始碼

API

Props

interface Props {
  appear?: boolean;
  type?: TransitionType;
}
/** 初始化後 name 變更會被忽略,其餘參數皆可動態調整 */
export type TransitionType =
  TransitionRect
  | TransitionConvergingRect
  | TransitionRound
  | TransitionFence
TransitionRect
export enum RectAction {
  SLIDE_RIGHT = 'slide-right',
  SLIDE_LEFT = 'slide-left',
  SLIDE_UP = 'slide-up',
  SLIDE_DOWN = 'slide-down',
  SCALE = 'scale',
  SCALE_X = 'scale-x',
  SCALE_Y = 'scale-y',
}
interface TransitionRect {
  name: 'rect';
  enter: {
    action: `${RectAction}`;
    duration: number;
    /** 每個 shape 延遲間距  */
    delay: number;
    easing: EasingOptions;
  };
  leave: {
    action: `${RectAction}`;
    duration: number;
    delay: number;
    easing: EasingOptions;
  };
  /** HEX 格式。顏色數量等同 shape 數量,至少需要一個 */
  colors: [string, ...string[]];
}
TransitionConvergingRect
export enum ConvergingRectAction {
  SLIDE = 'slide',
}
interface TransitionConvergingRect {
  name: 'converging-rect';
  enter: {
    action: `${ConvergingRectAction}`;
    angle?: number;
    duration: number;
    /** 每個 shape 延遲間距  */
    delay: number;
    easing: EasingOptions;
  };
  leave: {
    action: `${ConvergingRectAction}`;
    duration: number;
    delay: number;
    easing: EasingOptions;
  };
  /** HEX 格式。顏色數量等同 shape 數量,至少需要一個 */
  colors: [string, ...string[]];
}
TransitionRound
export enum RoundEnterAction {
  SPREAD_LEFT = 'spread-left',
  SPREAD_RIGHT = 'spread-right',
  SPREAD_UP = 'spread-up',
  SPREAD_DOWN = 'spread-down',
  SPREAD_SCALE = 'spread-scale',
}
export enum RoundBaseAction {
  SCALE_LT = 'scale-lt',
  SCALE_LB = 'scale-lb',
  SCALE_RT = 'scale-rt',
  SCALE_RB = 'scale-rb',
  SCALE = 'scale',
}
interface TransitionRound {
  name: 'round';
  enter: {
    action: `${RoundBaseAction}` | `${RoundEnterAction}`;
    duration: number;
    /** 每個 shape 延遲間距  */
    delay: number;
    easing: EasingOptions;
  };
  leave: {
    action: `${RoundBaseAction}`;
    duration: number;
    delay: number;
    easing: EasingOptions;
  };
  /** HEX 格式。顏色數量等同 shape 數量,至少需要一個 */
  colors: [string, ...string[]];
}
TransitionFence
export enum FenceAction {
  SPREAD_RIGHT = 'spread-right',
  SPREAD_LEFT = 'spread-left',
  SCALE_X = 'scale-x',
  SCALE_Y = 'scale-y',
}
interface TransitionFence {
  name: 'fence';
  enter: {
    action: `${FenceAction}`;
    duration: number;
    /** 每個 shape 延遲間距  */
    delay: number;
    easing: EasingOptions;
  };
  leave: {
    action: `${FenceAction}`;
    duration: number;
    delay: number;
    easing: EasingOptions;
  };
  /** HEX 格式。顏色數量等同 shape 數量,至少需要一個 */
  colors: [string, ...string[]];
}

Emits

const emit = defineEmits<{
  (e: 'init'): void;
  (e: 'beforeTransition'): void;
  (e: 'afterTransition'): void;
}>()

Slots

const slots = defineSlots<{
  default?: () => unknown;
}>()

v0.23.1