3.9 KiB
Anime.js Animation Integration — Design
Date: 2026-03-08 Status: Approved Branch: feat/vue-label-tab
Problem
The current animation system mixes CSS keyframes, CSS transitions, and imperative inline-style bindings across three files. The seams between systems produce:
- Abrupt ball pickup (instant scale/borderRadius jump)
- No spring snap-back on release to no target
- Rigid CSS dismissals with no timing control
- Bucket grid and badge pop on basic
@keyframes
Decision
Integrate Anime.js v4 as a single animation layer. Vue reactive state is unchanged; Anime.js owns all DOM motion imperatively.
Architecture
One new composable, minimal changes to two existing files, CSS cleanup in two files.
web/src/composables/useCardAnimation.ts ← NEW
web/src/components/EmailCardStack.vue ← modify
web/src/views/LabelView.vue ← modify
Data flow:
pointer events → Vue refs (isHeld, deltaX, deltaY, dismissType)
↓ watched by
useCardAnimation(cardEl, stackEl, isHeld, ...)
↓ imperatively drives
Anime.js → DOM transforms
useCardAnimation is a pure side-effect composable — returns nothing to the template. The cardStyle computed in EmailCardStack.vue is removed; Anime.js owns the element's transform directly.
Animation Surfaces
Pickup morph
animate(cardEl, { scale: 0.55, borderRadius: '50%', y: -80 }, { duration: 200, ease: spring(1, 80, 10) })
Replaces the instant CSS transform jump on onPointerDown.
Drag tracking
Raw cardEl.style.translate update on onPointerMove — no animation, just position. Easing only at boundaries (pickup / release), not during active drag.
Snap-back
animate(cardEl, { x: 0, y: 0, scale: 1, borderRadius: '1rem' }, { ease: spring(1, 80, 10) })
Fires on onPointerUp when no zone/bucket target was hit.
Dismissals (replace CSS @keyframes)
- fileAway —
animate(cardEl, { y: '-120%', scale: 0.85, opacity: 0 }, { duration: 280, ease: 'out(3)' }) - crumple — 2-step timeline: shrink + redden →
scale(0)+ rotate - slideUnder —
animate(cardEl, { x: '110%', rotate: 5, opacity: 0 }, { duration: 260 })
Bucket grid rise
animate(gridEl, { y: -8, opacity: 0.45 }) on isHeld → true; reversed on false. Spring easing.
Badge pop
animate(badgeEl, { scale: [0.6, 1], opacity: [0, 1] }, { ease: spring(1.5, 80, 8), duration: 300 }) triggered on badge mount via Vue's onMounted lifecycle hook in a BadgePop wrapper component or v-enter-active transition hook.
Constraints
Reduced motion
useCardAnimation checks motion.rich.value before firing any Anime.js call. If false, all animations are skipped — instant state changes only. Consistent with existing useMotion pattern.
Bundle size
Anime.js v4 core ~17KB gzipped. Only animate, spring, and createTimeline are imported — Vite ESM tree-shaking keeps footprint minimal. The draggable module is not used.
Tests
Existing EmailCardStack.test.ts tests emit behavior, not animation — they remain passing. Anime.js mocked at module level in Vitest via vi.mock('animejs') where needed.
CSS cleanup
Remove from EmailCardStack.vue and LabelView.vue:
@keyframes fileAway,crumple,slideUnder@keyframes badge-pop.dismiss-label,.dismiss-skip,.dismiss-discardclasses (Anime.js fires on element refs directly)- The
dismissClasscomputed inEmailCardStack.vue
Files Changed
| File | Change |
|---|---|
web/package.json |
Add animejs dependency |
web/src/composables/useCardAnimation.ts |
New — all Anime.js animation logic |
web/src/components/EmailCardStack.vue |
Remove cardStyle computed + dismiss classes; call useCardAnimation |
web/src/views/LabelView.vue |
Badge pop + bucket grid rise via Anime.js |
web/src/assets/avocet.css |
Remove any global animation keyframes if present |