Skip to content

Origami Transition transition

Turns an element transition into origami


Has anyone here played Paper Mario: The Origami King on the Switch?

The art direction, the music, the whole design — every part of it is wonderfully thought through.

It really is great, do give it a go! ദ്ദി ˉ͈̀꒳ˉ͈́ )

I have always loved the way the origami soldiers make their entrance, so I built the effect into a transition component! ( ´ ▽ ` )ノ

Tech Keywords

NameDescription
Babylon.js3D engine
DOM to ImageConverts DOM elements into images, based on SVG foreignObject
Mass-Spring SystemDiscretizes an object into mass points linked by springs, iteratively relaxing constraints to simulate cloth, rope, and other soft materials
IsometryA transform that preserves the distance between any two points — translation, rotation and reflection all qualify. Paper cannot stretch, so every fold is an isometry
Canvas ShaderWritten in GLSL and executed directly on the GPU, faster than the Canvas 2D API but harder to master
Dynamic TextureA texture that can be modified at runtime, commonly used to generate patterns or text on the fly
Vue TransitionBuilt-in component that animates the enter/leave of a single element
CSS Anchor PositioningNew CSS feature that positions elements relative to other elements (anchors)

Usage examples

Basic usage

Works just like Vue Transition: wrap a chunk of content whose visibility is controlled by v-if.

View example source code
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>

Feedback form

Try sending the form — do rate it first. ( ´ ▽ ` )ノ

View example source code
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>

How it works

The effect comes in two layers. The outer one intercepts Vue Transition events and hands the screen to a stunt double; the inner one is a self-contained origami solver. The two talk through init / enter / leave, the same idea as VFX Transition.

Placing the stunt double

The actor is a canvas laid directly over the real content with CSS Anchor Positioning. Its size is measured on the spot and scaled 6×, leaving room for the orbiting camera and for the flight out of frame; the snapshot is supersampled another 2–3× on top of that.

Anchor names and positioning styles are written inline instead of 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.

The crease pattern is fixed at 3:4. The texture is filled with paper colour first, then the content is laid on top at its native aspect, centred, and the camera lines up with that content cell. Orientation picks whichever of portrait 3:4 or landscape 4:3 sits closer to the content's aspect. The margin is cut away while the sheet is flat, so the visible area matches the DOM exactly, and only fades in as paper (paperColor) once folding starts.

Enter and leave

before-enter / before-leave attach the CSS anchor name, then init(el) captures a snapshot with snapdom and rebuilds the crease mesh. At that moment the sheet is flat and sits exactly on the real content.

Leave gathers the paper along its creases into shape, the camera pulls back to an orbiting view, and once folded the shape slides out of frame. Enter picks up from that same cell: it slides in, unfolds, the camera returns, and the real DOM fades back in.

The origami solver

A mass-spring kinematics solver, the same school as Origami Simulator.

  • Crease-pattern compilation: reads mountain and valley segments, snaps nearby vertices, splits intersections into a planar graph, then ear-clips it into a renderable mesh
  • Mass-spring solving: axial springs keep the paper inextensible, while each crease converges on its target angle under a dihedral constraint, scheduled into time windows in the order a pair of hands would fold it
  • Illustrated paper shading: a custom Babylon.js shader maps Half Lambert lighting into a soft gradient. The whole thing only ever subtracts — the brightest area keeps the snapshot's own colour, which is what keeps text readable
  • Layer depth offset: the vertex shader derives a fixed NDC depth offset from uv. Layers that end up stacked necessarily come from different places on the sheet, so their front-to-back order is pinned

Crumpling

The paper ball takes a different route. Crumpling has no creases to draw and no target dihedral angles to converge on, so the mass-spring solver has nothing to push against. What a pair of hands does is written straight into geometry instead: a chain of folds cut through space. Each fold gets a line; one side stays put while the other rolls over it. The lines close in on the centre of the sheet, and the roll direction flips with every fold.

  • A crease is an arc, not a corner: a sharp fold slices a sheet that already has thickness, tearing the two sides apart. An arc runs the same length as the material it came from, so the paper never stretches — and those arcs, which can never quite flatten, are exactly what gives the ball its volume
  • The arc centre sits on the middle of the stack, and its radius grows with the stack: a layer u away from that centre is scaled by exactly |1 − u / R|. Let the radius fall behind the thickness and the outermost layers are stretched several times over into shards — the first attempt peaked at 30× and ended up bigger than the flat sheet
  • Fine ridges come from a separate layer: thirteen folds only describe the broad shape. A field of triangle waves is laid over the flat sheet first, so the wrinkles roll up along with the paper
  • A uniform squeeze finishes the job: the closer a fold line gets to the centre, the thicker the paper it cuts, and the last few folds end up more of a caress than a crease. A uniform scale has no such problem — it is a similarity transform, so nothing in the texture is skewed, and it reads as the ball being squeezed ever tighter

The whole deformation is a pure function of progress; no solving, no baking. The same progress always gives the same ball, so the last frame of the leave and the first frame of the enter line up by construction, and playing it backwards is exactly the paper opening up again.

Animation timing

Step durations, the mid-air spin and the flight are all derived from the crease schedule, so swapping in a different fold re-times everything automatically.

  • Step durations run slow → fast → slow, weighting each step by the inverse of the easeInOutCirc slope, normalised to fill the whole run
  • Folding and unfolding each get their own timing structure
  • The sheet folds in mid-air, turning slowly as it goes, with the two spin axes on staggered time windows
  • Flight uses exponential easing: easeInExpo on the way out keeps it hanging in place before it snaps away, with offsets given in camera axes
  • Each shape has its own flying personality. The plane glides out to the upper right and enters from the other side of the frame; the rocket lifts off vertically with a ~10 Hz rumble and comes back down on a free-fall-plus-rebound curve; the paper crane crosses left to right, with depth offset and roll both held at zero; the paper ball is tossed off to the lower right, tumbling along a parabola, and what drops back in is a fresh sheet, falling from the upper left with a small bounce
  • Fade-out and fade-in windows are set separately for each direction

Source code

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