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. (。・∀・)ノ
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
<template>
<div class="w-full flex flex-col items-start gap-4 border border-gray-200 rounded-xl p-6">
<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 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">
{{ 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.
View example source code
<template>
<div class="w-full flex flex-col items-start gap-4 border border-gray-200 rounded-xl 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">
{{ 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>
<select
v-model="rectAction.enter.action"
class="flex-1 rounded bg-slate-200/50 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/50 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/50 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/50 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">
{{ t('離開 action') }}
</div>
<select
v-model="rectAction.leave.action"
class="flex-1 rounded bg-slate-200/50 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/50 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/50 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/50 p-2"
>
</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 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
<template>
<div class="w-full flex flex-wrap justify-center gap-4 border border-gray-200 rounded-xl 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 Transition
Like a fence, composed of multiple rectangles.
Click any fish to start the transition. (≧∇≦)ノ
View example source code
<template>
<div class="w-full flex flex-col justify-center gap-4 border border-gray-200 rounded-xl 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 Transition
Multiple rectangles converge toward the center, with adjustable tilt angle.
Click any fish to start the transition. (≧∇≦)ノ
View example source code
<template>
<div class="w-full flex flex-col justify-center gap-4 border border-gray-200 rounded-xl 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>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! (๑•̀ㅂ•́)و✧
Source Code
API
Props
interface Props {
appear?: boolean;
type?: TransitionType;
maskClass?: string;
}/** 初始化後 name 變更會被忽略,其餘參數皆可動態調整 */
export type TransitionType =
TransitionRect
| TransitionConvergingRect
| TransitionRound
| TransitionFenceTransitionRect
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;
}>()