Origami Transition transition
Wrap content in transition-paper and toggle it with v-if, the same as Vue's built-in Transition component. Instead of a plain fade, the content folds itself into a paper airplane and flies off screen when it disappears — and when it comes back, a paper airplane flies in and unfolds back into the content. ヾ(◍'౪`◍)ノ゙
The fold isn't a canned animation — a mass-spring solver actually creases a virtual sheet of paper along a real paper-airplane crease pattern, in roughly the order a pair of hands would fold it. ◝( •ω• )◟
Tech Keywords
| Name | Description |
|---|---|
| Babylon.js | 3D engine |
| DOM to Image | Converts DOM elements into images, based on SVG foreignObject |
| Dynamic Texture | A texture that can be modified at runtime, commonly used to generate patterns or text on the fly |
| Mass-Spring System | Discretizes an object into mass points linked by springs, iteratively relaxing constraints to simulate cloth, rope, and other soft materials |
| Physics Simulation | Simulates real-world physics such as gravity, collisions, and velocity |
| Vector Math | Math operations for direction, acceleration, velocity, and more |
| Vue Transition | Built-in component that animates the enter/leave of a single element |
| CSS Anchor Positioning | New CSS feature that positions elements relative to other elements (anchors) |
Usage examples
Basic usage
Its usage is the same as Vue's built-in Transition component — wrap the content controlled by v-if and let transition-paper fold it away.
Known limitations
The crease pattern is 3:4, so content of any other aspect leaves a margin. The actor first picks whichever orientation — portrait 3:4 or landscape 4:3 — sits closer to the content: a 320×215 card wastes 24.8 % per side in portrait but only 5.2 % in landscape. What margin remains is cut away while flat (so the flat frame matches the DOM exactly) and fades in as paper once folding starts, coloured by paperColor. The texture is filled with that colour before the content is drawn, so no pixel is ever transparent — untouched canvas is rgba(0, 0, 0, 0), and bilinear sampling drags that black into the content edge as a dark fringe. The margin is identified by a UV rect rather than by alpha, keeping alpha out of the pipeline entirely: dyeing transparent pixels on the CPU instead meant a getImageData/putImageData round trip, which un-premultiplies and re-premultiplies alpha and chews up the partially-transparent pixels that antialias text. Leaving it cut away throughout is what made the rocket lose its nose cone and fins — both sat inside those bands.
Content is captured with snapdom, which only snapshots what's inside the element's bounding box. Effects like box-shadow or outline that render outside that box won't be captured, so they'd vanish abruptly the instant the fold starts — best to avoid them on wrapped content.
A fish who loves to code, but has no fingers to type with and can't buy a computer that works underwater. ('◉◞⊖◟◉` )
Best at one ball sport: sweet potato balls. Takes on ten at a time. ( •̀ ω •́ )✧
View example source code
<template>
<div class="example-wrap w-full flex flex-col gap-4">
<div class="example-ctrl flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="text-nowrap">
{{ t('shapeLabel') }}
</div>
<select
v-model="shape"
class="example-select w-full flex-1"
>
<option
v-for="option in shapeOptionList"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<base-checkbox
v-model="visible"
:label="checkboxLabel"
/>
</div>
<div class="card-slot flex flex-1 items-center justify-center">
<transition-paper :shape="shape">
<div
v-if="visible"
class="card max-w-[90vw] w-80 flex flex-col gap-1 rounded p-6"
>
<div class="text-base font-bold">
{{ t('title') }}
</div>
<p class="text-sm">
{{ t('body') }}
</p>
<p class="text-sm">
{{ t('body2') }}
</p>
</div>
</transition-paper>
</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 TransitionPaper from '../transition-paper.vue'
const { t } = useI18n()
const visible = ref(true)
const shape = ref<PaperShape>('plane')
const checkboxLabel = computed(() => (visible.value ? t('hide') : t('show')))
const shapeOptionList = computed<{ value: PaperShape; label: string }[]>(() => [
{ value: 'plane', label: t('plane') },
{ value: 'rocket', label: t('rocket') },
])
</script>
<style lang="sass" scoped>
.card-slot
// 內容消失時會整個從 DOM 移除,沒有這個固定高度版面會塌陷、上下內容跟著跳動
min-height: 18rem
.card
// snapdom 快照不含元素邊界外效果,box-shadow 會在摺紙瞬間消失,故不使用
background: light-dark(#FFFDF5, #3A3830)
border: 1px solid light-dark(#E4DFCB, #55524A)
</style>How it works
- Intercept
Transitionevents - Create an actor and position it exactly over the real element using CSS Anchor, sized larger than the content so the folded airplane has room to swing into view without getting clipped
- Use
snapDOMto capture the DOM as an image — supersampled 2–3× so the texels have pixels to spare, since a texel grid can't line up with the pixel grid and bilinear sampling would otherwise soften the text — then feed it straight into aBabylon.jsmesh built from a paper-airplane crease pattern - During
enterandleave, hide the original DOM and show the actor, like a stunt double, same as VFX Transition ( •̀ ω •́ )✧ - Fold the mesh open or closed with a mass-spring physics solver, while the camera swings between lining up with the real content and orbiting the folded airplane
- Once folded, the plane glides out of frame —
enter()runs the same thing in reverse, gliding in before it unfolds - After the animation finishes, restore the original DOM and hide the actor
Folding Is Simulated, Not Keyframed
A crease pattern — the same mountain/valley diagram you'd follow folding a real paper airplane — compiles into a triangulated mesh once, then a mass-spring solver takes over every frame: axial springs keep every edge length fixed so the paper can't stretch, and each crease is pulled toward a target fold angle read off a fold schedule instead of all folding at once, so the plane creases in the order hands would — corner folds first, then the nose tip, then the body, then the wings. Every crease eases with easeInOutCubic and deliberately never overshoots: paper folded past its target punches through the layers beside it, and easeOutBack measured 176 % of the target angle.
shape picks what it folds into, swapping both the crease pattern and the flight behaviour. The paper plane is a wide-winged basic glider — blunt nose, large wings — chosen over a sharp dart because the content stays readable once it unfolds. The rocket is a ridged fold: a tapered nose cone, a body closed by two longitudinal creases (the outer one folded back to the front, raising a ridge down each side), and fins flaring at the tail.
Nose steepness is what makes a rocket read as one. A 45° corner fold always yields a 90° apex whose height equals half the body width — a squat wedge no matter how the body is tuned. Measured nose width as a fraction of the widest point: 45° gives 0.35–0.70, dropping the crease to 560 gives 0.26–0.46, and 720 gives 0.18–0.22. Past 880 the two flaps overlap badly, the profile ends up widest in the middle, and strain jumps to 6e-2.
The mesh and its texture are rebuilt from scratch on every init(), so the same actor can fold completely different content each time it plays — there's nothing to reset between uses.
Once folded, two pairs of paper layers end up just 0.005 world units apart while the solver still moves vertices up to 0.087 per frame, so they pass through each other every frame — on screen, the letter texture and the plain paper back strobing against each other. The vertex shader fixes the ordering with a constant NDC depth offset derived from uv: layers that stack necessarily come from different places on the sheet, so their uv differs and their front-to-back order is pinned. The offset is sized from measurement — big enough to outweigh the crossings, small enough not to shove genuinely-behind layers in front.
Timing Derived From the Fold Schedule
Step durations, the mid-air spin and the flight all fall out of the crease schedule, so swapping in a different pattern re-times everything automatically.
Steps run slow → fast → slow with the final one stretched further still. Easing the progress curve directly doesn't work: easeInOutCirc has zero derivative at both ends, so with only four steps the first one swallows 42.7 % of the time and the last 23.7 % idles at a standstill. Instead each step gets a weight from the inverse of the curve's slope, normalised to fill the run. Unfolding re-allocates those weights in its own play order rather than replaying the fold backwards — otherwise the final-step emphasis lands at the start of the unfold.
Each shape carries its own flight behaviour. The plane glides out to the upper right and enters from the opposite side; the rocket lifts off vertically with a ~10 Hz rumble and drops back in on a free-fall-plus-rebound curve, touching down once and bouncing about 1.9 world units before settling. Amplitude comes from the restitution coefficient rather than from diluting the curve: blending easeOutBounce 30 % with easeOutCubic to shrink it stopped the curve ever reaching zero at touchdown (it flattened at 0.18 and crept down), turning the bounce into a plateau that read as braking in mid-air. The rocket also holds still once folded: reusing the plane's timing had it moving 166 ms before the fold finished and tumbling 270° on the way, so the finished shape was never on screen at all.
The paper turns in mid-air rather than on an imagined table: no pauses needed to flip it over, so two spin axes run on overlapping-but-offset windows through the whole fold, eased with easeInOutSine — the gentlest common curve, peaking at just 1.57× speed. The flight uses exponential easing: easeInExpo out, so the plane barely drifts and then snaps away; easeOutExpo in, arriving fast and settling to near-zero velocity right as the unfold begins. Offsets are expressed in camera axes (right / up / away), because leaving the frame is a framing problem — receding alone only ever shrinks the plane without getting it out of the view frustum.
The return flight enters from the opposite side rather than retracing the exit. The nose is fixed relative to the sheet, so retracing would fly the plane in backwards; continuing along the same bearing keeps the nose pointing where it's going, reading as a plane that looped around off-screen and came back through. And it fades as it goes, transparent before it ever reaches the canvas edge, so it's never sliced off mid-flight.
The camera itself eases between two extremes: lined up flush with the real content, and pulled back into an orbit around the folded plane. Once the paper goes flat, a few extra high-precision solver passes settle it instead of leaving it subtly wrinkled.
Because the fold is triggered by a deliberate v-if toggle rather than looping in the background, prefers-reduced-motion: reduce doesn't turn it off — it just plays close to instantly instead.
Source code
API
Props
interface Props {
/** 初次渲染是否播放進場動畫
* @default false
*/
appear?: boolean;
/** 單趟轉場的總時長(摺疊/攤開加上飛行),單位 ms
* @default 2600
*/
duration?: number;
/** 摺紙造型
* @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;
}>()