avocet/docs/plans/2026-03-08-anime-animation-design.md

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)

  • fileAwayanimate(cardEl, { y: '-120%', scale: 0.85, opacity: 0 }, { duration: 280, ease: 'out(3)' })
  • crumple — 2-step timeline: shrink + redden → scale(0) + rotate
  • slideUnderanimate(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-discard classes (Anime.js fires on element refs directly)
  • The dismissClass computed in EmailCardStack.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