摺紙轉場 transition
將元素過度變成折紙藝術
大家有玩過 Switch 的遊戲「紙片瑪利歐:摺紙國王」嗎?
整體風格、美術設計、音樂與遊戲性都極具巧思
真的很讚,強力推薦大家玩玩看!ദ്ദി ˉ͈̀꒳ˉ͈́ )
我一直很喜歡裡面的摺紙兵登場的方式,所以將效果實作成過度元件了!( ´ ▽ ` )ノ
技術關鍵字
| 名稱 | 描述 |
|---|---|
| Babylon.js | 3D 引擎 |
| DOM to Image | 將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現 |
| 質點彈簧系統 | 把物體離散成質點並以彈簧相連,透過反覆鬆弛約束模擬布料、繩索等柔性材質 |
| 等距變換 | 保持任兩點距離不變的變換,平移、旋轉、鏡射皆屬之。紙不可拉伸,摺紙的每一摺都是等距變換 |
| Canvas Shader | 使用 GLSL 開發,直接在 GPU 上執行,比 Canvas 2D API 更快,但也更難 |
| Dynamic Texture | 可動態修改的材質,常用於即時產生圖案或文字 |
| Vue Transition | 內建元件,可以處理單個項目的進出動畫 |
| CSS Anchor Positioning | CSS 新功能,允許元素相對於其他元素(錨點)進行定位 |
使用範例
基本用法
用法與 Vue Transition 相同,包住一段被 v-if 控制顯示的內容即可。

擅長球類是地瓜球,習慣一打十
查看範例原始碼
<template>
<div class="example-wrap w-full flex flex-col gap-4">
<div
class="example-ctrl grid grid-cols-4 items-center gap-2"
:class="{ 'pointer-events-none': isTransitioning }"
>
<select-stepper
v-model="shape"
:label="t('shapeLabel')"
:options="shapeOptionList"
:option-label-map="shapeLabelMap"
class="col-span-4"
/>
<div
class="col-span-4 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"
/>
</div>
</div>
<div
class="card-slot flex flex-1 items-center justify-center"
:class="{ 'pointer-events-none': isTransitioning }"
>
<transition-origami
:shape="shape"
@enter="isTransitioning = true"
@leave="isTransitioning = true"
@after-enter="isTransitioning = false"
@after-leave="isTransitioning = false"
>
<div
v-if="visible"
class="card max-w-[90vw] w-80 flex flex-col items-center gap-2 p-6"
>
<img
src="/low/profile.webp"
alt=""
class="mb-4 h-[180px] w-[180px] overflow-hidden border-4 border-white rounded-full shadow-xl"
>
<div class="text-xl">
{{ t('codfishName') }}
</div>
<p class="text-center text-sm">
{{ t('body') }}
</p>
</div>
</transition-origami>
</div>
</div>
</template>
<script setup lang="ts">
import type { PaperShape } from '../use-paper-solver'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import SelectStepper from '../../select-stepper.vue'
import TransitionOrigami from '../transition-origami.vue'
const { t } = useI18n()
const isTransitioning = ref(false)
const visible = ref(true)
const shape = ref<PaperShape>('plane')
const shapeOptionList: PaperShape[] = ['plane', 'rocket', 'flapping-bird', 'crumpled-ball']
const shapeLabelMap = computed<Record<PaperShape, string>>(() => ({
'plane': t('plane'),
'rocket': t('rocket'),
'flapping-bird': t('bird'),
'crumpled-ball': t('crumpledBall'),
}))
</script>
<style lang="sass" scoped>
.card-slot
// 內容消失時會整個從 DOM 移除,沒有這個固定高度版面會塌陷、上下內容跟著跳動
min-height: 26rem
.card
// snapdom 快照不含元素邊界外效果,box-shadow 會在摺紙瞬間消失,故不使用
background: light-dark(#FFFDF5, #3A3830)
border: 1px solid light-dark(#E4DFCB, #55524A)
</style>回饋表單
送出表單看看吧,記得先評分。( ´ ▽ ` )ノ
查看範例原始碼
<template>
<div class="example-wrap w-full flex justify-center py-8">
<div
class="w-full flex items-start justify-center"
:class="{ 'pointer-events-none': isTransitioning }"
:style="{ minHeight: slotMinHeight > 0 ? `${slotMinHeight}px` : undefined }"
>
<transition-origami
:shape="shape"
@enter="isTransitioning = true"
@leave="isTransitioning = true"
@after-enter="handleAfterEnter"
@after-leave="handleAfterLeave"
>
<div
v-if="visible"
ref="cardRef"
class="feedback-card relative max-w-[90vw] w-96 rounded-2xl p-8"
>
<!-- 蓋板蓋上後,底下的表單不該再被 Tab 走到;inert 要傳 undefined 才會真的移除屬性 -->
<form
class="flex flex-col gap-4"
novalidate
:inert="isSent || undefined"
@submit.prevent="handleSubmit"
>
<div class="text-2xl font-bold">
{{ t('title') }}
</div>
<p class="leading-relaxed opacity-80">
{{ t('description') }}
</p>
<div class="flex items-center gap-1">
<button
v-for="score in SCORE_LIST"
:key="score"
type="button"
class="feedback-star"
:class="{ 'feedback-star--active': score <= rating }"
:aria-label="t('scoreLabel', { score })"
:aria-pressed="score <= rating"
@click="rating = score"
>
<svg
class="h-6 w-6"
viewBox="0 0 24 24"
:fill="score <= rating ? 'currentColor' : 'none'"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1-5.4-2.9-5.4 2.9 1-6.1L3.2 9.5l6.1-.9L12 3Z" />
</svg>
</button>
</div>
<textarea
v-model="message"
rows="4"
name="chill-feedback-field"
class="feedback-input resize-none"
:placeholder="t('placeholder')"
@focus="errorMessage = ''"
/>
<!-- 常駐佔位,只切換透明度,錯誤訊息出現時版面不會位移 -->
<span
class="min-h-5 text-sm text-red-500 transition-opacity"
:class="errorMessage ? 'opacity-100' : 'opacity-0'"
aria-live="polite"
>
{{ errorMessage }}
</span>
<base-btn
class="feedback-submit w-full"
@click="handleSubmit"
>
<span class="inline-flex items-center justify-center gap-2">
<svg
class="feedback-plane h-[1.1em] w-[1.1em] opacity-70"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 3 3 10.5l7 3 3 7L21 3Z" />
<path d="m10 13.5 4.6-4.6" />
</svg>
{{ t('send') }}
</span>
</base-btn>
</form>
<!-- 送出後整張卡片被蓋住,像另一張紙從上緣翻下來 -->
<transition name="feedback-cover">
<div
v-if="isSent"
class="feedback-cover"
role="status"
>
<svg
class="feedback-cover-icon h-10 w-10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 20.5 4.3 13a4.6 4.6 0 0 1 6.5-6.5l1.2 1.2 1.2-1.2A4.6 4.6 0 1 1 19.7 13L12 20.5Z" />
</svg>
<span class="text-xl font-bold">
{{ t('sent') }}
</span>
<span class="text-sm opacity-70">
{{ t('sentNote') }}
</span>
</div>
</transition>
</div>
</transition-origami>
</div>
</div>
</template>
<script setup lang="ts">
import type { PaperShape } from '../use-paper-solver'
import { useElementSize, useTimeoutFn } from '@vueuse/core'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import TransitionOrigami from '../transition-origami.vue'
const SCORE_LIST = [1, 2, 3, 4, 5]
/** 滿分才算滿意,少一顆星都不算 */
const FULL_SCORE = Math.max(...SCORE_LIST)
const { t } = useI18n()
const isTransitioning = ref(false)
const visible = ref(true)
/**
* 滿分的回饋摺成紙飛機送出去,空白表單由紙鶴帶回來;
* 不滿分的則當場揉成一團丟掉,再掉一張新的下來
*/
const shape = ref<PaperShape>('plane')
const rating = ref(0)
const message = ref('')
const errorMessage = ref('')
/** 送出後先讓致謝的蓋板翻下來,等這段過渡看完才讓卡片離開 */
const isSent = ref(false)
const cardRef = ref<HTMLElement>()
const { height: cardHeight } = useElementSize(cardRef, undefined, { box: 'border-box' })
/** 記住卡片曾達到的最大高度撐住容器,飛走時版面不會跳動 */
const slotMinHeight = ref(0)
watch(cardHeight, (height) => {
slotMinHeight.value = Math.max(slotMinHeight.value, height)
})
/**
* 蓋板落定再收走卡片,快照才會帶著「感謝您的回饋」離開。
*
* 蓋板連同圖示總共播 0.62 秒,剩下的 0.48 秒是留給人看完那句話的
*/
const { start: startFlyAwayTimer } = useTimeoutFn(() => {
visible.value = false
}, 1100, { immediate: false })
/** 卡片離開後空一陣子,空白表單才回來 */
const { start: startReturnTimer } = useTimeoutFn(() => {
visible.value = true
}, 1500, { immediate: false })
function handleSubmit() {
if (isTransitioning.value || !visible.value || isSent.value)
return
if (message.value.trim() === '') {
errorMessage.value = t('empty')
return
}
errorMessage.value = ''
isSent.value = true
/** 星等在這一刻定案:等一下清空表單時 rating 就歸零了 */
shape.value = rating.value === FULL_SCORE ? 'plane' : 'crumpled-ball'
startFlyAwayTimer()
}
function handleAfterLeave() {
isTransitioning.value = false
// 趁卡片不在畫面上清空,回來就是全新的表單
rating.value = 0
message.value = ''
isSent.value = false
// 紙飛機飛走了,換紙鶴帶新表單回來;紙團則是再掉一張下來,維持同一個造型
if (shape.value === 'plane') {
shape.value = 'flapping-bird'
}
startReturnTimer()
}
function handleAfterEnter() {
isTransitioning.value = false
}
</script>
<style lang="sass" scoped>
.feedback-card
// snapdom 快照不含元素邊界外效果,box-shadow 會在摺紙瞬間消失,故不使用
background: light-dark(#FFFDF5, #3A3830)
border: 1px solid light-dark(#E4DFCB, #55524A)
color: light-dark(#374151, #d1d5db)
// 蓋板要從上緣翻下來,透視得由卡片提供
perspective: 900px
.feedback-cover
position: absolute
inset: 0
z-index: 1
display: flex
flex-direction: column
align-items: center
justify-content: center
gap: 0.5rem
padding: 2rem
border-radius: inherit
// 上緣壓一道暗、往下收成紙色:那是摺線落下時的陰影,
// 少了它,蓋板與底下的卡片同色,讀起來只是換了內容而不是另一張紙蓋上來。
// 一樣避開 box-shadow,snapdom 拍不到元素邊界外的效果
background: light-dark(linear-gradient(#F3ECD8, #FFFEF9 38%), linear-gradient(#37342D, #45423A 38%))
text-align: center
// 轉軸在上緣,紙就是從那裡翻下來的
transform-origin: top center
// 右上角摺起一角,與送出鍵同一套語彙
&::after
content: ''
position: absolute
top: 0
right: 0
width: 1.1rem
height: 1.1rem
border-top-right-radius: inherit
background: linear-gradient(225deg, light-dark(#E7E0C6, #4E4B42) 50%, transparent 50%)
.feedback-cover-icon
color: light-dark(#D9737E, #E0919A)
.feedback-cover-enter-active
transition: opacity 0.2s ease-out, transform 0.44s cubic-bezier(0.2, 1.04, 0.3, 1)
.feedback-cover-enter-from
opacity: 0
transform: rotateX(-92deg)
.feedback-cover-leave-active
transition: opacity 0.18s ease
.feedback-cover-leave-to
opacity: 0
// 圖示晚一步才冒出來,蓋板落定的節奏因此有先後。
// 用轉場而非 keyframes:snapdom 是複製節點再拍,keyframes 會在複製品上從頭播,
// 快照剛好拍到第一格
.feedback-cover-enter-active .feedback-cover-icon
transition: opacity 0.3s 0.2s ease-out, scale 0.42s 0.2s cubic-bezier(0.2, 1.3, 0.4, 1)
.feedback-cover-enter-from .feedback-cover-icon
opacity: 0
scale: 0.5
@media (prefers-reduced-motion: reduce)
.feedback-cover-enter-active
transition: opacity 0.2s ease-out
.feedback-cover-enter-from
transform: none
.feedback-cover-enter-active .feedback-cover-icon
transition: opacity 0.3s 0.1s ease-out
.feedback-cover-enter-from .feedback-cover-icon
scale: 1
.feedback-card .feedback-submit
position: relative
overflow: hidden
padding: 0.625rem 0.875rem
border-radius: 0.75rem
border-color: light-dark(#D9D2B8, #55524A)
// 一樣避開 box-shadow,質感改由紙色漸層與摺角撐住
background: light-dark(linear-gradient(#FFFEF9, #F4EEDA), linear-gradient(#454239, #3A3830))
letter-spacing: 0.02em
&:active
background: light-dark(linear-gradient(#F7F1DE, #EDE6D0), linear-gradient(#3E3B33, #33312B))
// 右上角摺起一角,呼應摺紙
&::after
content: ''
position: absolute
top: 0
right: 0
width: 0.85rem
height: 0.85rem
background: linear-gradient(225deg, light-dark(#E7E0C6, #4E4B42) 50%, transparent 50%)
.feedback-plane
transition: translate 0.25s ease
&:hover .feedback-plane
translate: 0.15rem -0.1rem
@media (prefers-reduced-motion: reduce)
.feedback-plane
transition: none
.feedback-star
color: light-dark(#C9C1A4, #6B675C)
transition: color 0.15s, scale 0.15s
&:hover
scale: 1.12
&--active
color: light-dark(#E0A93B, #E3B457)
@media (prefers-reduced-motion: reduce)
transition: color 0.15s
&:hover
scale: 1
.feedback-input
padding: 0.625rem 0.875rem
border: 1px solid light-dark(#CCC, #555)
border-radius: 0.75rem
background: light-dark(#FAFAFA, #2a2a2a)
outline: none
transition: border-color 0.15s
&:focus
border-color: light-dark(#60a5fa, #3b82f6)
</style>原理
摺紙效果分兩層,外層攔截 Vue Transition 事件、把畫面交給替身,內層是一套獨立的摺紙運算引擎,兩者透過 init/enter/leave 溝通,概念與 VFX 轉場 同源。
替身定位
Actor 是一顆 Canvas,用 CSS Anchor Positioning 疊在真實內容正上方,尺寸現場量測後放大 6 倍,留出環繞運鏡與飛出畫面的餘裕,快照另外超取樣 2~3 倍。
錨點名稱與定位樣式全寫在行內,不放進全域樣式,免得與其他元件的同名類名在正式版互相覆蓋;瀏覽器不支援錨點定位時,改用現場量測的座標。
摺痕圖固定 3:4,貼圖先鋪滿紙色再把內容以原生比例置中疊上,鏡頭以「內容那一格」對齊;擺向取直放 3:4 與橫放 4:3 之中較接近內容比例者。留白攤平時裁掉,可見範圍與 DOM 完全一致,起摺後才淡入成紙色(paperColor)。
進出場流程
before-enter/before-leave 掛上 CSS Anchor Name,init(el) 用 snapdom 擷取快照、重建摺痕網格,此刻紙張攤平、與真實內容完全重合。
Leave 沿摺痕收攏成型,鏡頭拉開至環繞視角,摺畢滑出畫面。Enter 從那一格接手,滑入、攤平、鏡頭歸位,最後真實 DOM 淡入。
摺紙運算引擎
質點彈簧運動學求解器,作法與 Origami Simulator 同派。
- 摺痕圖編譯:讀入山摺、谷摺線段,吸附鄰近頂點、切割交點成平面圖,耳切三角化成可渲染網格
- 質點彈簧求解:軸向彈簧維持紙張不可拉伸,摺痕依二面角約束朝目標角收斂,並依手摺順序排入時間窗
- 插畫式紙張渲染:Babylon.js 自訂 shader,Half Lambert 明暗映射成柔和漸層。整套只做減法,最亮處保持快照原色,文字才讀得清楚
- 疊層深度偏移:頂點 shader 依 uv 算固定的 NDC 深度偏移,會疊在一起的紙層必定來自紙上不同位置,前後關係就此釘死
揉紙
紙團不走摺痕圖那條路。揉紙沒有摺痕可畫,也沒有目標二面角可收斂,質點彈簧求解器無從施力,因此改把手掌的動作直接寫成幾何:一連串在空間中切下去的摺。每一摺給一條摺線,線的一側不動、另一側繞著它捲過去;摺線由紙緣一路逼近紙心,捲向逐摺反轉。
- 摺痕是一段圓弧,不是尖角:尖角摺會把有厚度的紙團切開,摺線兩側瞬間錯開。圓弧的弧長與材料等長,紙面不會被拉長,紙團的體積也正是這些捲不平的圓弧撐出來的
- 弧心對準紙疊中線,半徑隨紙厚放大:離弧心
u的一層,伸縮率恰為|1 − u / R|。半徑追不上厚度時,最外層會被拉成好幾倍長的碎片——實測初版最大拉伸 30 倍,揉完的紙團反而比整張紙還大 - 細碎的稜線另外疊一層:十三摺只交代得了大輪廓。攤平的紙面先疊一層三角波起伏再送進摺疊鏈,皺褶就會跟著紙一路捲進去
- 最後等比收攏:摺線越靠近紙心,切過的紙越厚,最後幾摺軟得像在撫平。等比縮放沒有這個包袱,它是相似變換,貼圖不會被扯歪,讀起來就是紙團被越攥越小
整趟變形是進度的純函數,既不必求解也不必烘焙:同一個進度必定得到同一團紙,離場的最後一格與進場的第一格天生對得上,倒著播就是把紙攤開。
動畫節奏
時長分配、憑空自轉與飛行都由摺痕排程推導,換一份摺法會自動跟著改。
- 步驟時長走「慢 → 快 → 慢」,以「步驟」為單位取
easeInOutCirc斜率的倒數當權重,正規化鋪滿整段時間 - 正轉、倒轉各有自己的時間結構
- 整體姿態走憑空摺紙,紙一邊摺一邊緩緩轉動,兩個自轉軸各佔一段錯落的時間窗
- 飛行採指數緩動,飛離用
easeInExpo先黏在原地再驟然抽離,位移以鏡頭座標給出 - 每種造型各有飛行性格。紙飛機斜滑出右上方、從畫面另一側進場;火箭垂直升空、邊震邊竄(約 10 Hz),落下照自由落體與回彈建構;紙鶴由左往右橫渡,深度位移與翻滾一律為零;紙團往右下方隨手一丟,邊翻邊飛畫一道拋物線,掉回來的是另一張新的紙,從左上方落下、彈幾下才停穩
- 淡出、淡入的時間窗兩個方向分開
原始碼
API
Props
interface Props {
/** 初次渲染是否播放進場動畫
* @default false
*/
appear?: boolean;
/** 單趟轉場的總時長(摺疊/攤開加上飛行),單位 ms
* @default 2600
*/
duration?: number;
/** 摺紙造型。plane 紙飛機、rocket 火箭、flapping-bird 紙鶴、crumpled-ball 紙團
* @default 'plane'
*/
shape?: PaperShape;
/** 內容填不滿紙面時,補上的紙色(十六進位)
* @default '#FFFFFF'
*/
paperColor?: string;
}Emits
const emit = defineEmits<{
(e: 'enter'): void;
(e: 'afterEnter'): void;
(e: 'leave'): void;
(e: 'afterLeave'): void;
}>()Slots
defineSlots<{
default?: () => unknown;
}>()