Skip to content

摺紙轉場 transition

將元素過度變成折紙藝術


大家有玩過 Switch 的遊戲「紙片瑪利歐:摺紙國王」嗎?

整體風格、美術設計、音樂與遊戲性都極具巧思

真的很讚,強力推薦大家玩玩看!ദ്ദി ˉ͈̀꒳ˉ͈́ )

我一直很喜歡裡面的摺紙兵登場的方式,所以將效果實作成過度元件了!( ´ ▽ ` )ノ

技術關鍵字

名稱描述
Babylon.js3D 引擎
DOM to Image將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現
質點彈簧系統把物體離散成質點並以彈簧相連,透過反覆鬆弛約束模擬布料、繩索等柔性材質
等距變換保持任兩點距離不變的變換,平移、旋轉、鏡射皆屬之。紙不可拉伸,摺紙的每一摺都是等距變換
Canvas Shader使用 GLSL 開發,直接在 GPU 上執行,比 Canvas 2D API 更快,但也更難
Dynamic Texture可動態修改的材質,常用於即時產生圖案或文字
Vue Transition內建元件,可以處理單個項目的進出動畫
CSS Anchor PositioningCSS 新功能,允許元素相對於其他元素(錨點)進行定位

使用範例

基本用法

用法與 Vue Transition 相同,包住一段被 v-if 控制顯示的內容即可。

查看範例原始碼
vue
<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>

回饋表單

送出表單看看吧,記得先評分。( ´ ▽ ` )ノ

查看範例原始碼
vue
<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 事件、把畫面交給替身,內層是一套獨立的摺紙運算引擎,兩者透過 initenterleave 溝通,概念與 VFX 轉場 同源。

替身定位

Actor 是一顆 Canvas,用 CSS Anchor Positioning 疊在真實內容正上方,尺寸現場量測後放大 6 倍,留出環繞運鏡與飛出畫面的餘裕,快照另外超取樣 2~3 倍。

錨點名稱與定位樣式全寫在行內,不放進全域樣式,免得與其他元件的同名類名在正式版互相覆蓋;瀏覽器不支援錨點定位時,改用現場量測的座標。

摺痕圖固定 3:4,貼圖先鋪滿紙色再把內容以原生比例置中疊上,鏡頭以「內容那一格」對齊;擺向取直放 3:4 與橫放 4:3 之中較接近內容比例者。留白攤平時裁掉,可見範圍與 DOM 完全一致,起摺後才淡入成紙色(paperColor)。

進出場流程

before-enterbefore-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;
}>()

v0.86.3