Skip to content

Every little fish swimming around is a visitor on this page right now, and one of them is you ( ´ ▽ ` )ノ If they get in the way of reading, feel free to turn them off.

Shape Transition transition ​

A Motion Graphic style Transition component.

What are Motion Graphic transitions? Check out this video:

Motion Graphic transitions are quite common in video production, but not so much on the web.

So I tried bringing these effects to the web! ˋ( ° ▽、° )

While there are still a few minor imperfections, the basic functionality and effects are all implemented. The rest can be polished another day. (。・∀・)ノ

Tech Keywords ​

NameDescription
Babylon.js3D engine
JS AnimationJavaScript-driven animation for more complex, precise control; popular libraries include GSAP and anime.js
CSS Anchor PositioningNew CSS feature that positions elements relative to other elements (anchors)
Vue TransitionBuilt-in component that animates the enter/leave of a single element
Anime.jsLightweight JavaScript animation library

Usage Examples ​

Basic Usage ​

Based on Vue's built-in Transition component. Note that rapid consecutive switching may cause issues, so please go easy on it for now. (〃` 3′〃)

It works even when the old and new elements have different sizes! ˋ( ° ▽、° )

鱈魚
View example source code
vue
<template>
  <div class="w-full flex flex-col items-start gap-4 example-wrap">
    <base-btn
      class="w-full"
      :label="t('更換')"
      @click="change()"
    />

    <div class="w-full flex flex-col gap-4 overflow-hidden border rounded bg-slate-300/40 dark:bg-slate-700/40 p-6">
      <div class="flex justify-center">
        <transition-shape
          :type="imgTransition"
          @after-transition="handleReady"
        >
          <img
            :key="index"
            :src="profile"
            alt=""
            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">
        {{ t('鱈魚') }}
        <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 { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import TransitionShape from '../transition-shape.vue'

const { t } = useI18n()

const index = ref(0)

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

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

const introductionList = computed(() => [
  t('intro1'),
  t('intro2'),
  t('intro3'),
])
const introduction = computed(() => introductionList.value[index.value % introductionList.value.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>

Transition Parameters ​

You can fine-tune various transition parameters.

顏色
進入 action
easing
delay (ms)
duration (ms)
離開 action
easing
delay (ms)
duration (ms)
View example source code
vue
<template>
  <div class="example-wrap w-full flex flex-col items-start gap-4">
    <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">
          {{ t('顏色') }}
        </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">
            {{ t('進入 action') }}
          </div>
          <base-select
            v-model="rectAction.enter.action"
            :options="rectActionOptions"
            class="flex-1"
          />
        </div>

        <div class="w-full flex items-center gap-3 p-1">
          <div class="flex-1 text-right">
            easing
          </div>
          <base-select
            v-model="rectAction.enter.easing"
            :options="easingOptions"
            class="flex-1"
          />
        </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="example-input min-w-0 flex-1"
          >
        </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="example-input min-w-0 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">
            {{ t('離開 action') }}
          </div>
          <base-select
            v-model="rectAction.leave.action"
            :options="rectActionOptions"
            class="flex-1"
          />
        </div>

        <div class="w-full flex items-center gap-3 p-1">
          <div class="flex-1 text-right">
            easing
          </div>
          <base-select
            v-model="rectAction.leave.easing"
            :options="easingOptions"
            class="flex-1"
          />
        </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="example-input min-w-0 flex-1"
          >
        </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="example-input min-w-0 flex-1"
          >
        </div>
      </div>

      <base-btn
        class="w-full"
        :label="t('換魚')"
        @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 { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseSelect from '../../base-select.vue'
import TransitionShape from '../transition-shape.vue'
import { RectAction } from '../type'

const { t } = useI18n()

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 Transition ​

Composed of circles.

Click any fish to start the transition. (≧∇≦)ノ

▲ 點擊展開
View example source code
vue
<template>
  <div class="w-full flex flex-wrap justify-center gap-4 example-wrap">
    <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 Transition ​

Like a fence, composed of multiple rectangles.

Click any fish to start the transition. (≧∇≦)ノ

▲ 點擊展開
View example source code
vue
<template>
  <div class="w-full flex flex-col justify-center gap-4 example-wrap">
    <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 Transition ​

Multiple rectangles converge toward the center, with adjustable tilt angle.

Click any fish to start the transition. (≧∇≦)ノ

▲ 點擊展開
View example source code
vue
<template>
  <div class="w-full flex flex-col justify-center gap-4 example-wrap">
    <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>

How It Works ​

Based on Vue's built-in Transition component, it uses babylon.js to generate shape masks, then controls animation start and end through Transition Events.

It can handle the following scenarios:

  • v-if conditional rendering
  • Changing element key

v-show scenarios are not considered for now.

Positioning Issue ​

Originally, fixed positioning combined with page scroll events was used to position the canvas, but the shapes would slightly offset during fast scrolling.

While puzzling over this, I discovered the CSS Anchor Positioning API, which perfectly solved the problem! (๑•̀ㅂ•́)و✧

Anchor name and positioning styles are written inline rather than in global styles, so class names shared with other components cannot override each other in a production build. Where the browser lacks anchor positioning, measured coordinates take over.

📚 CSS Anchor Positioning API

Source Code ​

API ​

Props ​

interface Props {
  appear?: boolean;
  type?: TransitionType;
  maskClass?: string;
}
/** 初始化後 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.90.7