返回 Skills
mblode/agent-skills· MIT 内容可用

ui-animation

>-

安装

与 skills.sh 相同的 Command / Prompt 安装方式


name: ui-animation description: >- Designs, implements, reviews, debugs, and reverse-engineers UI motion: CSS transitions, keyframes, springs, gestures, drag, easing, timing, framer-motion, and animation curves from screen recordings. Use when asked to "add animations", "make this feel smooth", "review my animations", "add a swipe gesture", "match this easing", "reverse engineer this animation", "extract the animation curve", or "what's it called when..." to name a motion effect from a vague description. For visual direction use ui-design; for page-level UI audit use ui-audit.

UI Animation

  • IS: designing, implementing, reviewing, debugging UI motion (springs, gestures, drag, easing, CSS transitions, keyframes, framer-motion), measuring motion from a recording (extract frames, track, fit curves) to emit code plus a handoff spec, and naming a described motion effect (reverse-lookup vocabulary).
  • IS NOT: choosing overall visual direction, palettes, or typography (use ui-design), auditing a whole page's UI quality (use ui-audit), or named text-effect specs (use the external animate-text skill where installed).

Canonical home for reverse-engineering motion from a recording: route "reverse engineer this animation" and "match this easing" here, not to a separate skill. If the input is a screen recording or video, you are MEASURING motion: follow the Reverse-engineer workflow. Otherwise (designing, implementing, reviewing) use the rules and Workflow below.

Reference files

FileRead when
references/decision-framework.mdDefault: deciding whether/why to animate, picking easing character
references/spring-animations.mdSpring physics, framer-motion useSpring, configuring spring params, Apple damping/response values, interruption mechanics
references/component-patterns.mdButtons, popovers, tooltips, drawers, modals, toasts with animation
references/clip-path-techniques.mdclip-path for reveals, tabs, hold-to-delete, comparison sliders
references/gesture-drag.mdDrag, swipe-to-dismiss, momentum, pointer capture, velocity handoff, momentum projection
references/performance-deep-dive.mdJank, CSS vs JS, WAAPI, CSS variables trap, Framer Motion caveats
references/review-format.mdReviewing animation code: ten standards (each with flag-on-sight triggers), Before/After/Why table, Block/Approve verdict
references/contextual-animations.mdContextual icon swaps, word-level stagger entrances, fixed-offset exits
references/transition-recipes.mdInstalling a CSS transition: card resize, badge, dropdown, modal, panel, page slide, icon swap, number pop-in, text swap, success, avatar hover, error shake
references/measurement-guide.mdReverse-engineer: what to measure, eye vs script, reading metrics.json, choosing an ROI
references/curve-fitting.mdReverse-engineer: reading fit_curves.py output, spring vs bezier, judging fit error, asymmetric open/close
references/code-output.mdReverse-engineer: emitting code for CSS, Motion/Framer Motion, SwiftUI, React Native, UIKit
references/choreography.mdReverse-engineer: multi-element/multi-phase motion: staggers, blur-before-move, per-edge settling
references/vocabulary.mdNaming a motion effect the user describes vaguely ("what's it called when...")

Core rules

  • Animate for feedback, orientation, continuity, or deliberate delight. If it's just "it looks cool" and the user sees it often, don't.
  • Never animate keyboard-initiated actions (shortcuts, arrow navigation, tab/focus); they repeat constantly and animation makes them feel slow.
  • Prefer CSS transitions for interruptible UI: keyframes restart from zero on interruption, transitions retarget. Use keyframes only for predetermined sequences.
  • Implementation priority: CSS transitions > WAAPI > CSS keyframes > JS (requestAnimationFrame); under load CSS stays smooth while JS drops frames.
  • Asymmetric timing: occasional interactions can enter slightly slower, exit fast. High-frequency ephemeral UI (hover highlights, popovers, panel toggles) inverts this: enter instantly (0ms), exit with a brief fade (100-150ms) so the action feels immediate.
  • Use @starting-style for DOM entry; fall back to a data-mounted attribute where unsupported.
  • A small filter: blur(2px) hides rough crossfades between swapped content.

Motion design principles

  • Continuity over teleportation. Elements visible in both states transition in place; expand from where elements sit rather than fading in a new instance. Never duplicate a persistent element or hard-cut between views that share components; hard cuts lose spatial context.
  • Directional motion matches position. Tab and carousel transitions animate in the direction matching spatial layout (left-to-right forward, right-to-left back).
  • Emerge from the trigger. Overlays, trays, and panels animate outward from the element that opened them; generic centre-screen entrances break spatial orientation.
  • Animate paired states together. If open animates, close animates. If hover has motion, focus and pressed states get equivalent feedback. Do not polish only one half of a repeated interaction.
  • Delight scales inversely with frequency. Rarer interactions get more personality; high-frequency actions must be invisible.
  • Motion enhances perceived speed. Smooth transitions feel faster than hard cuts, even at identical load times.

What to animate

  • Movement: transform and opacity only; they skip layout and paint.
  • State feedback: color, background-color, and opacity are acceptable.
  • Never animate layout properties (width, height, top, left); they trigger layout recalc every frame. (Exception: a deliberate container resize tween, see the card-resize recipe.)
  • Never use transition: all; it animates unintended properties and silently adopts future ones. List them explicitly.
  • Avoid filter animation for core interactions; if unavoidable keep blur ≤ 20px (heavy blur is expensive, especially in Safari).
  • SVG: apply transforms on a <g> wrapper with transform-box: fill-box; transform-origin: center; without it they rotate/scale around the canvas origin.
  • transform: scale() also scales children (icons, text, borders scale proportionally), unlike width/height: a feature for press feedback, but account for it when an inner element must stay fixed-size.
  • Disable transitions during theme switches ([data-theme-switching] * { transition: none !important }), or every themed property animates at once.

Easing defaults

ElementDurationEasing
Button press feedback100-160mscubic-bezier(0.22, 1, 0.36, 1)
Tooltips, small popovers125-200msease-out or enter curve
Dropdowns, selects150-250mscubic-bezier(0.22, 1, 0.36, 1)
Modals, drawers200-350mscubic-bezier(0.22, 1, 0.36, 1)
Move/slide on screen200-300mscubic-bezier(0.25, 1, 0.5, 1)
Page transitions250-400msenter or move curve
Simple hover (colour/opacity)200msease
Illustrative/marketingUp to 1000msSpring or custom

Keep routine UI under 300ms; scale duration with distance (a full-screen slide can exceed 300ms, a 6px tooltip shift stays under 150ms).

Named curves

  • Enter: cubic-bezier(0.22, 1, 0.36, 1) for entrances and transform-based hover
  • Move: cubic-bezier(0.25, 1, 0.5, 1) for slides, drawers, panels
  • Drawer (iOS-like): cubic-bezier(0.32, 0.72, 0, 1)

Avoid ease-in for UI: it starts slow, so the element lags the user's action and feels sluggish. Prefer custom curves from easing.dev over built-in ease/ease-out, whose gentle acceleration reads soft, not decisive.

Transition decision rules

Match the UI element first, then pick the recipe from references/transition-recipes.md:

UI patternRecipe
Trigger + floating dot/countNotification badge
Trigger + anchored surfaceMenu dropdown
Centred surface on top of pageModal dialog
Panel sliding into existing containerPanel reveal
List ↔ detail or wizard stepsPage side-by-side slides
Element dimension changesCard resize
Text updating in placeText state swap
Two icons in same slotIcon swap
Number updatingNumber pop-in
Confirmation / success momentSuccess celebration
Hovering item in horizontal stackAvatar group hover
Form validation errorError state shake

Prefer lower-overhead transitions (CSS-only) unless the design requires JS orchestration.

Spatial and sequencing

  • Popover transform-origin at the trigger (modals stay center), dialog/menu entrances from scale(0.85-0.9) not scale(0), and 30-50ms staggers (total under 300ms, most important element leading). Full rules and code in references/component-patterns.md and references/contextual-animations.md.
  • Paired elements rule: elements that animate together (modal + overlay, tooltip + arrow, FAB + label) must share easing and duration. Mismatched timing is the usual cause of "something feels off".

Accessibility

  • Every animation needs a prefers-reduced-motion: reduce path: disable transform/keyframe motion, keep instant state changes or opacity-only fades. All recipes include the guard.
  • Gate hover animations behind @media (hover: hover) and (pointer: fine), or touch devices replay hover on tap. Tailwind v4 hover: utilities apply this automatically; skip the manual query there.
  • During direct manipulation, keep the element locked to the pointer with no easing; add easing only after release.

Performance

  • Pause looping animations off-screen with IntersectionObserver; they burn GPU even when invisible.
  • Toggle will-change only during heavy motion and only for transform/opacity; remove it after. Each promotion costs compositor memory; permanent promotion across many elements is worse than none.
  • Do not animate drag via CSS variables on a container; every update recalculates styles for all children. Set transform directly on the moving element.
  • Motion x/y values are the default for axis movement and drag (they bypass React re-renders). Use a full transform string only when one owner must combine multiple transform functions or interop with non-Motion code.
  • See references/performance-deep-dive.md for WAAPI, compositing layers, and the CSS vs JS comparison table.

Anti-patterns

High-signal failures not covered above:

  • Animating on mount without a user trigger: unexpected motion disorients; the user did nothing to cause it.
  • Hard stops on drag boundaries feel broken; apply friction/damping so movement diminishes past it (see gesture-drag reference).
  • Animating both a container and staggering its children: pick one entrance per container. If the panel slides in, its content should already be visible on arrival.
  • Tooltip animation after the first is open: subsequent tooltips in the group open instantly, or the toolbar feels laggy.

(The transform-owner clash and keyframes-on-rapid-fire failures are stated canonically under Performance and Core rules above.)

Workflow

Copy and track:

Animation progress:
- [ ] Step 1: Decide whether the interaction should animate
- [ ] Step 2: Choose purpose, easing, and duration
- [ ] Step 3: Pick the implementation style
- [ ] Step 4: Load the relevant component or technique reference
- [ ] Step 5: Validate timing, interruption, and device behavior
  1. Answer the four questions in references/decision-framework.md: animate? purpose? easing? speed?
  2. Pick duration from the easing defaults table above.
  3. Choose implementation: CSS transition > WAAPI > spring > keyframe > JS.
  4. Load the reference for your component or technique.
  5. When reviewing, apply the strict posture in references/review-format.md: measure against the ten standards, output the Before/After/Why table, then a tiered verdict ending in a Block/Approve decision.

Validation

Produce evidence for each check (DevTools observations, not "looks fine"):

  • Grep the diff for layout property transitions (width, height, top, left) and transition: all.
  • Retoggle components rapidly; confirm transitions retarget instead of restarting from zero.
  • Slow to 10% in the DevTools Animations panel to catch timing and transform-origin issues invisible at full speed.
  • Emulate prefers-reduced-motion: reduce (DevTools Rendering panel) and confirm every animation has a reduced path.
  • Confirm will-change is toggled around animations, not permanently set, and looping animations pause off-screen.
  • Test touch interactions on real devices; simulators under-report gesture and hover-on-tap issues.
  • Review again with fresh eyes the next day; imperfections missed during development stand out.

Reverse-engineer workflow

Use this branch to measure an existing animation from a screen recording, then emit code and a handoff spec that reproduce it. The scripts under scripts/ are the canonical, deterministic path; run them rather than reconstructing their logic.

Dependencies: ffmpeg for frame extraction (brew install ffmpeg); Python with pip install opencv-python numpy scipy for tracking and curve fitting. Degrades gracefully: with only ffmpeg you can extract frames and reason visually; tracking and fitting need the Python packages.

Reverse-engineer progress:
- [ ] Step 1: Extract frames + contact sheet (per direction if open differs from close)
- [ ] Step 2: Vision pass: identify element, effects, phases
- [ ] Step 3: Decide precision (eye-only vs scripted)
- [ ] Step 4: Track motion and fit curves (if escalating)
- [ ] Step 5: Annotate choreography (delays, asymmetry)
- [ ] Step 6: Emit code for the target(s)
- [ ] Step 7: Validate against the recording
  1. Extract. Run python3 scripts/extract_frames.py <video> <outdir>. Trim to just the transition with --start/--duration; if the interaction has both an open and a close, trim two windows and run the pipeline once per direction (they are almost never mirror images). Match --fps to the source (probe with ffprobe), never sampling above the source rate. Open contact_sheet.png first.
  2. Vision pass. Name the element(s) that move, every effect (translate, scale often anisotropic, opacity, blur, corner radius, shadow, color), and the phases, noting which property leads and lags. Use the checklist in references/measurement-guide.md.
  3. Decide precision. Simple fade or linear slide: read timing off the contact sheet, skip to step 5. Elastic, springy, or multi-property motion: escalate to step 4 (eyeballing a spring is unreliable).
  4. Track and fit. Run python3 scripts/track_motion.py <outdir> for metrics.json (pass --bbox X,Y,W,H to isolate one element), then python3 scripts/fit_curves.py <outdir>/metrics.json for spring params, cubic-bezier, and per-property fit error. Pass the same --fps you extracted with. Read references/curve-fitting.md to pick the model; high error on both means multi-phase motion (split and fit each segment).
  5. Annotate. Load references/choreography.md. Build the timing-offset table (when each property starts and settles); lead/lag gaps and over-stretch carry more feel than any single curve.
  6. Emit. Substitute fitted parameters into the templates in references/code-output.md for the target. Keep movement on transform/opacity. Emit two transitions when open and close differ, plus the consolidated handoff spec so it can be implemented without the video.
  7. Validate. Re-derive: play the emitted animation, screen-record it, run it back through extract_frames.py, and compare contact sheets side by side. Slow to 0.1x to confirm phase order and over-stretch survive. Confirm the code only animates transform, opacity, and filter.

Reverse-engineer gotchas:

  • fit_curves.py defaults to --fps 30: extract at 60 but fit at the default and every duration_ms doubles while fitted stiffness drops to a quarter. Always pass the extraction fps to the fit.
  • Sampling above the source rate duplicates frames: a 24 fps GIF extracted at 60 inflates fit error with plateaued runs in metrics.json. Probe and match the source rate.
  • Screen recordings drop frames and iOS/QuickTime captures are variable-frame-rate; consecutive identical rows are duplicated frames, not a pause. Re-record at a steadier rate if plateaus dominate.
  • Measure open and close as separate clips and report two curves; never fit one and reuse it reversed (see references/choreography.md). Treat a fit error above 0.08 as suspect.

Related skills

  • ui-design: visual direction, palettes, typography; settle the visual system before tuning motion.
  • ui-audit: page/feature-level UI quality audit; its motion findings route back here for fixes.
  • Optional external animate-text skill where installed: curated named text effects (typewriter, line reveal, stagger builds) with exact JSON specs.

附带文件

references/choreography.md
# Choreography

A single fitted curve is rarely the whole story. Polish lives in *orchestration*: what
leads, lags, and settles independently. Annotate these from the contact sheet and frame
timeline, then express them per target.

## Contents

- Patterns to look for
- Reading timing offsets
- Expressing it per target

## Patterns to look for

| Pattern | What you see in the frames | Why it reads well |
|---|---|---|
| Blur-before-move | Element/backdrop blurs or dims a few frames *before* position changes | Sets context first; the move feels grounded, not abrupt |
| Over-stretch then settle | One axis scales past 1.0, then eases back | Soft and physical, not rigid |
| Per-edge / independent settling | Bottom and side edges arrive on different frames | Mimics real material; avoids a mechanical pop |
| Staggered children | List items/icons enter one after another | Builds hierarchy; the eye follows the lead |
| Tucked origin | Top edge stays clipped under a notch/island for the first third | Anchors it to where it came from |
| Asymmetric open/close | Open slow + springy, close fast + flat | Open invites attention; close gets out of the way |

## Reading timing offsets

For each property, note the **first frame it changes** and the **frame it settles** from
`metrics.json`. Convert to ms with `frame / fps * 1000`. Gaps between properties are the
choreography:

```
opacity:  starts f0   settles f6    (0 -> 200ms)
blur:     starts f0   settles f8    (0 -> 267ms)
translate:starts f3   settles f14   (100ms -> 467ms)   <- move lags blur by ~100ms
scaleY:   starts f3   peaks f11     overshoots to 1.06  <- over-stretch
```

That table *is* the spec. The delays (blur leads, move lags 100ms, scale overshoots) carry
more feel than any single easing curve.

## Expressing it per target

- **Stagger:** Motion `transition={{ staggerChildren: 0.04 }}`; CSS `animation-delay` per
  item; SwiftUI `.delay(i * 0.04)`; Reanimated `withDelay(i * 40, ...)`.
- **Lead/lag between properties:** give each its own delay/duration. CSS: comma-separate
  transitions (`transform 300ms ... 100ms, filter 200ms ... 0ms`). Motion/Reanimated:
  separate values with their own `delay`.
- **Over-stretch:** a keyframe past 1.0 (CSS `scaleY(1.06)` at 70%) or a spring with
  `overshoot: true`; don't flatten to a monotonic ease.
- **Independent edge settling:** animate `scaleX`/`scaleY` (or `transform-origin`-anchored
  edges) on separate curves, not uniform `scale`.
- **Asymmetric open/close:** two distinct transitions; enter slower/springier, exit
  faster/flatter. Never reuse the open curve reversed.
references/clip-path-techniques.md
# clip-path for Animation

`clip-path` is hardware-accelerated and creates effects impossible with `opacity` and `transform` alone.

## Contents
- [The inset shape](#the-inset-shape)
- [Tab colour transitions](#tab-colour-transitions)
- [Hold-to-delete](#hold-to-delete)
- [Image reveals on scroll](#image-reveals-on-scroll)
- [Comparison sliders](#comparison-sliders)

## The inset shape

`clip-path: inset(top right bottom left)` clips a rectangle. Each value eats into the element from that side.

```css
/* Fully hidden from right */
.hidden { clip-path: inset(0 100% 0 0); }

/* Fully visible */
.visible { clip-path: inset(0 0 0 0); }
```

Transition between states:

```css
.reveal {
  clip-path: inset(0 100% 0 0);
  transition: clip-path 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.reveal.active {
  clip-path: inset(0 0 0 0);
}
```

## Tab colour transitions

Duplicate the tab list. Style the copy as active (different background and text colour). Clip it so only the active tab shows. Animate the clip on tab change. This gives a seamless colour transition that per-tab `color` timing can't match.

```css
.tabs-active-overlay {
  clip-path: inset(0 var(--clip-right) 0 var(--clip-left));
  transition: clip-path 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
```

Update `--clip-left` and `--clip-right` via JS on tab change.

## Hold-to-delete

Put `clip-path: inset(0 100% 0 0)` on a coloured overlay. On `:active`, transition to `inset(0 0 0 0)` over 2s `linear`. On release, snap back with 200ms `ease-out`. Add `scale(0.97)` on the button for press feedback.

```css
.delete-overlay {
  clip-path: inset(0 100% 0 0);
  transition: clip-path 200ms ease-out;
}

.delete-button:active .delete-overlay {
  clip-path: inset(0 0 0 0);
  transition: clip-path 2s linear;
}
```

## Image reveals on scroll

Start hidden from bottom with `clip-path: inset(0 0 100% 0)`. Animate to `inset(0 0 0 0)` on viewport entry.

```tsx
"use client";
import { useRef, useEffect, useState } from "react";

export function RevealImage({ src, alt }: { src: string; alt: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(
      ([entry]) => { if (entry.isIntersecting) setVisible(true); },
      { threshold: 0.1, rootMargin: "-100px" }
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  return (
    <div
      ref={ref}
      style={{
        clipPath: visible ? "inset(0 0 0 0)" : "inset(0 0 100% 0)",
        transition: "clip-path 800ms cubic-bezier(0.77, 0, 0.175, 1)",
      }}
    >
      <img src={src} alt={alt} />
    </div>
  );
}
```

## Comparison sliders

Overlay two images. Clip the top with `clip-path: inset(0 50% 0 0)`. Adjust the right inset by drag position. No extra DOM, fully hardware-accelerated.

```css
.comparison-top {
  clip-path: inset(0 var(--split) 0 0);
}
```

Update `--split` from pointer events on the handle.
references/code-output.md
# Code Output

Templates that turn fitted parameters into runnable code per target. Substitute numbers from `fit_curves.py`; keep movement on `transform`/`opacity` (never layout props).

## Contents

- CSS
- Motion / Framer Motion
- SwiftUI
- React Native (Reanimated)
- UIKit
- Handoff spec
- Notes

Throughout: `D` = `duration_ms`, `BEZIER` = fitted `cubic-bezier(...)`, `{k, c, m}` = fitted `stiffness, damping, mass`.

## CSS

Monotonic: use the fitted bezier:

```css
.element {
  transition: transform Dms BEZIER, opacity Dms ease-out;
}
```

Overshoot/spring: a bezier can't ring, so sample the spring into `linear()`:

```css
/* generated from the spring response; more points = smoother overshoot */
.element {
  transition: transform Dms linear(0, 0.42 12%, 1.08 46%, 0.98 68%, 1);
}
```

Multi-phase: each phase is a keyframe stop with its own easing:

```css
@keyframes morph {
  0%   { transform: translateY(-12px) scaleY(0.9); filter: blur(6px); opacity: 0; }
  35%  { filter: blur(0); opacity: 1; }            /* blur/opacity lead the move */
  70%  { transform: translateY(0) scaleY(1.06); }  /* over-stretch */
  100% { transform: translateY(0) scaleY(1); }     /* settle */
}
```

## Motion / Framer Motion

Spring (preferred when the fit shows overshoot):

```tsx
<motion.div
  initial={{ y: -12, opacity: 0, filter: "blur(6px)" }}
  animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
  transition={{ type: "spring", stiffness: k, damping: c, mass: m }}
/>
```

Tween (monotonic):

```tsx
transition={{ duration: D / 1000, ease: [x1, y1, x2, y2] }} // fitted bezier control points
```

## SwiftUI

```swift
withAnimation(.spring(response: RESPONSE, dampingFraction: ZETA)) {
    isOpen = true   // drive layout/offset/scale off this state
}
// RESPONSE = 2 * .pi * sqrt(m / k);  ZETA = fitted `zeta`
```

Monotonic alternative:

```swift
withAnimation(.timingCurve(x1, y1, x2, y2, duration: D / 1000.0)) { isOpen = true }
```

## React Native (Reanimated)

```ts
// spring
offset.value = withSpring(target, { stiffness: k, damping: c, mass: m });

// monotonic
offset.value = withTiming(target, {
  duration: D,
  easing: Easing.bezier(x1, y1, x2, y2),
});
```

## UIKit

Spring: `CASpringAnimation` carries fitted params directly:

```swift
let a = CASpringAnimation(keyPath: "transform.translation.y")
a.stiffness = k; a.damping = c; a.mass = m
a.fromValue = -12; a.toValue = 0
a.duration = a.settlingDuration   // let the physics decide
layer.add(a, forKey: "morph")
```

Monotonic: `UIViewPropertyAnimator` with fitted bezier control points:

```swift
let curve = UICubicTimingParameters(controlPoint1: CGPoint(x: x1, y: y1),
                                     controlPoint2: CGPoint(x: x2, y: y2))
let animator = UIViewPropertyAnimator(duration: D / 1000.0, timingParameters: curve)
animator.addAnimations { view.transform = .identity; view.alpha = 1 }
animator.startAnimation()
```

## Handoff spec

A self-contained artifact someone can implement without the video. Emit one per direction (open and close), filled from `fit_curves.py` + the choreography table:

```markdown
## Motion spec: <element> (open)

Duration: <duration_ms> ms · Trigger: <what starts it>

| Property | Model | Params | Easing / config | Fit err |
|----------|-------|--------|-----------------|---------|
| translate | bezier | n/a | cubic-bezier(0.32,0,0,1) | 0.012 |
| scaleY | spring | k=180 c=14 m=1 | overshoot, zeta 0.53 | 0.024 |
| opacity | bezier | n/a | ease-out, 0-220ms | 0.02 |
| blur | n/a | 8px→0 | leads move by ~100ms | n/a |

Choreography: blur+opacity lead; translate lags ~100ms; scaleY over-stretches to 1.06
then settles. Bottom/side edges settle independently.

Reference implementation (<target>):
<chosen snippet from above>
```

Pair with the original `contact_sheet.png` so the reviewer can eyeball result against source.

## Notes

- Map fitted numbers onto each API's own parameters; don't hardcode a different look than measured.
- Web targets follow the repo's motion rules (animate `transform`/`opacity` only, interruptible). Hand the spec to the `ui-animation` skill to productionize.
- Emit **two** transitions when open and close differ (see `references/curve-fitting.md` and `references/choreography.md`); a shared transition flattens the asymmetry.
references/component-patterns.md
# Component Animation Patterns

## Contents
- [Buttons](#buttons)
- [Popovers and dropdowns](#popovers-and-dropdowns)
- [Tooltips](#tooltips)
- [Drawers and panels](#drawers-and-panels)
- [Modals and dialogs](#modals-and-dialogs)
- [Toasts](#toasts)
- [Crossfade transitions](#crossfade-transitions)
- [Lists and stagger](#lists-and-stagger)
- [Hover effects](#hover-effects)
- [Step form navigation](#step-form-navigation)
- [3D transforms](#3d-transforms)

## Buttons

Add `transform: scale(0.97)` on `:active` for instant press feedback.

```css
.button {
  transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.button:active {
  transform: scale(0.97);
}
```

`scale(0.9)` is too aggressive: the button visibly collapses, drawing the eye to the shrinking rather than the action. Press feedback should be felt, not seen; stay in the `0.96-0.98` range.

Mask imperfect crossfade between button states with blur:

```css
.button-content.transitioning {
  filter: blur(2px);
  opacity: 0.7;
}
```

Blur under 20px; heavy blur is expensive, especially in Safari.

## Popovers and dropdowns

Scale in from the trigger point, not from center; the default `transform-origin: center` is wrong for popovers.

```css
/* Radix UI */
.popover {
  transform-origin: var(--radix-popover-content-transform-origin);
}

/* Data attribute fallback */
.popover[data-side="top"]    { transform-origin: bottom center; }
.popover[data-side="bottom"] { transform-origin: top center; }
.popover[data-side="left"]   { transform-origin: center right; }
.popover[data-side="right"]  { transform-origin: center left; }
```

Start at `scale(0.88)`, never `scale(0)`: nothing appears from nothing.

```css
.menu {
  transform: scale(0.88);
  opacity: 0;
  transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1),
              opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.menu[data-open="true"] {
  transform: scale(1);
  opacity: 1;
}
```

## Tooltips

Delay first appearance (300-500ms) to prevent accidental activation. Once one tooltip is open, subsequent ones open instantly.

```css
.tooltip {
  transition: transform 125ms ease-out, opacity 125ms ease-out;
  transform-origin: var(--transform-origin);
}
.tooltip[data-starting-style],
.tooltip[data-ending-style] {
  opacity: 0;
  transform: scale(0.97);
}
.tooltip[data-instant] {
  transition-duration: 0ms;
}
```

## Drawers and panels

Use the move easing curve. Percentage `translateY`/`translateX` adapts to any height.

```css
.drawer {
  transform: translateY(100%);
  transition: transform 240ms cubic-bezier(0.25, 1, 0.5, 1);
}
.drawer[data-open="true"] {
  transform: translateY(0);
}
```

```tsx
<motion.aside
  initial={{ transform: "translate3d(100%, 0, 0)" }}
  animate={{ transform: "translate3d(0, 0, 0)" }}
  exit={{ transform: "translate3d(100%, 0, 0)" }}
  transition={{ duration: 0.24, ease: [0.25, 1, 0.5, 1] }}
/>
```

## Modals and dialogs

**Exception: modals keep `transform-origin: center`.** They're app-level state, not anchored to a trigger.

Use `@starting-style` for entry animations without JavaScript:

```css
.modal {
  opacity: 1;
  transform: scale(1);
  transition: opacity 250ms cubic-bezier(0.22, 1, 0.36, 1),
              transform 250ms cubic-bezier(0.22, 1, 0.36, 1);

  @starting-style {
    opacity: 0;
    transform: scale(0.95);
  }
}
```

Fall back to the `data-mounted` attribute pattern when `@starting-style` browser support is insufficient.

## Toasts

Enter and exit from the same direction for spatial consistency (makes swipe-to-dismiss intuitive).

```css
.toast {
  transform: translate3d(0, 6px, 0);
  opacity: 0;
  transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
              opacity 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.toast[data-open="true"] {
  transform: translate3d(0, 0, 0);
  opacity: 1;
}
```

Use CSS transitions (not keyframes) for toasts: added rapidly, and keyframes restart on interruption while transitions retarget smoothly.

## Crossfade transitions

When the container is small or outgoing/incoming content are structurally similar, a full directional slide adds too much visual weight; use a crossfade with a subtle directional hint instead.

```css
.view-enter {
  opacity: 0;
  transform: translateY(8px);
  filter: blur(4px);
  transition: opacity 150ms ease-out, transform 150ms ease-out, filter 150ms ease-out;
}
.view-enter-active {
  opacity: 1;
  transform: translateY(0);
  filter: blur(0);
}
```

Crossfade candidates: nav content swaps, tab panels with similar structure, small card state changes. The 8px shift signals "the view changed" without the visual weight of content traveling across the screen.

## Lists and stagger

Keep stagger delays short (30-50ms per item); total under 300ms.

```css
.item {
  opacity: 0;
  transform: translateY(8px);
  transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
              opacity 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.list[data-open="true"] .item {
  opacity: 1;
  transform: translateY(0);
}
.list[data-open="true"] .item:nth-child(2) { transition-delay: 50ms; }
.list[data-open="true"] .item:nth-child(3) { transition-delay: 100ms; }
.list[data-open="true"] .item:nth-child(4) { transition-delay: 150ms; }
```

```tsx
const listVariants = {
  show: { transition: { staggerChildren: 0.05 } },
};
```

Never block interaction while stagger animations are playing.

When removing items, use `AnimatePresence mode="popLayout"` so the exiting element is pulled out of document flow immediately and siblings start reflowing in parallel with the exit. The default mode waits for exit to finish before siblings move, causing sequential rather than parallel motion.

```tsx
<AnimatePresence mode="popLayout">
  {items.map((item) => (
    <motion.div
      key={item.id}
      layout
      exit={{ opacity: 0, scale: 0.8 }}
      transition={{ duration: 0.15 }}
    />
  ))}
</AnimatePresence>
```

## Hover effects

Gate hover animations behind a media query to avoid false positives on touch.

```css
@media (hover: hover) and (pointer: fine) {
  .link {
    transition: color 200ms ease, opacity 200ms ease;
  }
  .link:hover {
    opacity: 0.8;
  }
}
```

Fix hover flicker: apply hover on the parent, animate the child. `translateY` on the target itself moves the element out from under the cursor at the bottom edge, ending the hover and looping infinitely.

```css
.box:hover .box-inner {
  transform: translateY(-20%);
}
.box-inner {
  transition: transform 200ms ease;
}
```

For scale-based hover, use `scale(1.01)` to `scale(1.02)`; `scale(1.05)` is visibly inflated. Hover transitions should be 100-150ms; 300ms feels laggy because the user's eye is already on the element.

```css
@media (hover: hover) and (pointer: fine) {
  .card {
    transition: transform 120ms cubic-bezier(0.22, 1, 0.36, 1);
  }
  .card:hover {
    transform: scale(1.015);
  }
}
```

## Step form navigation

Forward steps slide content left (like reading); backward steps slide content right (like undoing). Animating both directions the same way breaks the user's mental model of forward vs backward progress.

```tsx
const variants = {
  enter: (direction: number) => ({
    x: direction > 0 ? 100 : -100,
    opacity: 0,
  }),
  center: { x: 0, opacity: 1 },
  exit: (direction: number) => ({
    x: direction > 0 ? -100 : 100,
    opacity: 0,
  }),
};

<AnimatePresence mode="wait" custom={direction}>
  <motion.div
    key={step}
    custom={direction}
    variants={variants}
    initial="enter"
    animate="center"
    exit="exit"
    transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
  />
</AnimatePresence>
```

## 3D transforms

For depth effects (card flips, coin spins, orbits), use `rotateX()`/`rotateY()` with `transform-style: preserve-3d` on the wrapper: stays on the GPU, needs no JavaScript. Reserve it for illustrative or delight moments, not high-frequency UI.

```css
.flip {
  transform-style: preserve-3d;
  transition: transform 400ms cubic-bezier(0.22, 1, 0.36, 1);
}
.flip[data-flipped="true"] {
  transform: rotateY(180deg);
}
.flip .front,
.flip .back {
  backface-visibility: hidden;
}
.flip .back {
  transform: rotateY(180deg);
}
```

Set `perspective` on the parent (e.g. `perspective: 1000px`) to control depth intensity; smaller values exaggerate the effect. As with SVG, set `transform-box: fill-box; transform-origin: center` if the rotation pivots around the wrong point.
references/contextual-animations.md
# Contextual Animations

Patterns for icon swaps, word-level stagger entrances, and subtle exits.

## Contents
- [Contextual icon swaps](#contextual-icon-swaps)
- [Word-level stagger entrances](#word-level-stagger-entrances)
- [Subtle exit animations](#subtle-exit-animations)

---

## Contextual icon swaps

For contextual state swaps (copy → check, play → pause, send → sent), animate `opacity`, `scale`, and `blur` together: the swap feels responsive, not instant, and blur hides the crossfade seam between outgoing and incoming icons.

**Motion (preferred, supports springs):**

```tsx
import { AnimatePresence, motion } from "motion/react"

<button onClick={handleCopy}>
  <AnimatePresence mode="wait" initial={false}>
    {isCopied ? (
      <motion.span
        key="check"
        initial={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
        animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
        exit={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
        transition={{ type: "spring", duration: 0.2, bounce: 0 }}
      >
        <CheckIcon />
      </motion.span>
    ) : (
      <motion.span
        key="copy"
        initial={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
        animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
        exit={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
        transition={{ type: "spring", duration: 0.2, bounce: 0 }}
      >
        <CopyIcon />
      </motion.span>
    )}
  </AnimatePresence>
</button>
```

**CSS only:**

```css
.icon {
  transition:
    opacity 150ms ease,
    scale 150ms ease,
    filter 150ms ease;
}

.icon[data-hidden] {
  opacity: 0;
  scale: 0.8;
  filter: blur(4px);
  pointer-events: none;
}
```

`mode="wait"` makes the exit finish before the enter starts, so both icons are never visible at once.

---

## Word-level stagger entrances

For hero text or page-header entrances, split content into sections (or words) and stagger each. Combine `opacity + translateY + blur`; any property alone looks flat, mechanical, or cheap.

**Two levels of stagger:**

| Level | Delay | Use for |
|-------|-------|---------|
| Section-level | 100ms per section | Title block, description block, button group |
| Word-level | 80ms per word | Hero headline only |

**CSS pattern:**

```css
@keyframes enter {
  from {
    transform: translateY(8px);
    filter: blur(5px);
    opacity: 0;
  }
}

.animate-enter {
  animation: enter 800ms cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
  animation-delay: calc(var(--delay, 0ms) * var(--stagger, 0));
}

/* Section level: 100ms gaps */
.animate-enter-section {
  --delay: 100ms;
}

/* Word level: 80ms gaps */
.animate-enter-word {
  --delay: 80ms;
}
```

**Section-level JSX:**

```tsx
<div className="animate-enter animate-enter-section" style={{ "--stagger": 1 }}>
  <Title />
</div>
<div className="animate-enter animate-enter-section" style={{ "--stagger": 2 }}>
  <Description />
</div>
<div className="animate-enter animate-enter-section" style={{ "--stagger": 3 }}>
  <Buttons />
</div>
```

**Word-level JSX:**

```tsx
{"Track expenses, build habits".split(" ").map((word, i) => (
  <span
    key={word}
    className="animate-enter animate-enter-word inline-block"
    style={{ "--stagger": i + 1 }}
  >
    {word}&nbsp;
  </span>
))}
```

These differ from the general-purpose 30-50ms item stagger in `component-patterns.md`: use 30-50ms for lists, 80-100ms for page-level entrances where each chunk carries narrative weight.

---

## Subtle exit animations

Exits should be directional (signal where content goes) but quieter than enters. Use a small fixed offset, not the computed element height.

**Full exit (too much movement for overlays):**

```tsx
<motion.div
  exit={{
    opacity: 0,
    y: "calc(-100% - 4px)", // the full height, plus gap
    filter: "blur(4px)",
  }}
  transition={{ type: "spring", duration: 0.45, bounce: 0 }}
/>
```

**Subtle exit (recommended):**

```tsx
<motion.div
  initial={{ opacity: 0, y: "calc(-100% - 4px)", filter: "blur(4px)" }}
  animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
  exit={{
    opacity: 0,
    y: "-12px", // fixed value, regardless of element height
    filter: "blur(4px)",
  }}
  transition={{ type: "spring", duration: 0.45, bounce: 0 }}
/>
```

Keep `-12px` fixed, never computed from dimensions: the exit conveys direction, not the full path. Enter uses full distance to build presence; exit uses a short fixed distance to release attention quietly.

Spring: `{ type: "spring", duration: 0.45, bounce: 0 }`; zero bounce for a clean exit.
references/curve-fitting.md
# Curve Fitting

Read `fit_curves.py` output, choose spring vs cubic-bezier, judge fit quality, handle
asymmetric open/close curves.

## Contents

- Reading the output
- Spring vs bezier
- Judging fit error
- Asymmetric open/close
- Converting spring params across APIs

## Reading the output

`fit_curves.py` prints one block per property that moved:

```json
{
  "duration_ms": 520,
  "properties": {
    "translate": {
      "spring": { "stiffness": 210.4, "damping": 28.0, "mass": 1.0,
                  "zeta": 0.97, "overshoot": false, "error": 0.018 },
      "bezier":  { "cubic_bezier": [0.32, 0.0, 0.0, 1.0],
                   "css": "cubic-bezier(0.32, 0.0, 0.0, 1.0)", "error": 0.012 },
      "recommended": "bezier"
    },
    "scaleY": {
      "spring": { "stiffness": 180.0, "damping": 14.2, "mass": 1.0,
                  "zeta": 0.53, "overshoot": true, "error": 0.024 },
      "bezier":  { "cubic_bezier": [0.18, 1.42, 0.30, 1.0],
                   "css": "cubic-bezier(0.18, 1.42, 0.30, 1.0)", "error": 0.061 },
      "recommended": "spring"
    }
  }
}
```

Here `scaleY` overshoots (`zeta` 0.53) while `scaleX` barely moves: the vertical
over-stretch-then-settle that makes a morph feel fluid. Fitting the axes separately surfaces it.

- `recommended` is just whichever model had the lower `error`. Sanity-check against what you
  saw: a clear overshoot should pick spring or a bezier with `y1`/`y2` > 1.
- `overshoot: true` (`zeta` < 1) means the motion rings past its target and settles back, the
  elastic, springy feel. `zeta` ≈ 1 is a crisp ease with no bounce; `zeta` > 1 is slow and heavy.

## Spring vs bezier

| Pick spring when | Pick bezier when |
|---|---|
| Motion overshoots / bounces / settles | Motion is monotonic (no overshoot) |
| Target API is spring-native (Motion, SwiftUI, Reanimated) | Target is CSS `transition`/`@keyframes` |
| Duration should emerge from physics | Duration is fixed and known |
| It must stay interruptible mid-flight | One-shot, non-interruptible play |

You can ship a spring *as* a bezier (the fit gives both), but a true overshoot needs a bezier
whose `y1`/`y2` exceed 1, or CSS `linear()` with sampled points; a spring expressed as a plain
ease-out loses the bounce. The fitter caps `y1`/`y2` at 1.5, so a bigger bounce fits better as
a spring (or a sampled `linear()`), never as a bezier.

## Judging fit error

`error` is normalized RMS against 0->1 progress, so it's comparable across properties.

| error | Reading |
|---|---|
| < 0.03 | Tight fit: use the parameters directly |
| 0.03-0.08 | Decent: eyeball the recommended curve against the contact sheet |
| > 0.08 | Suspect: usually multi-phase motion (see below), wrong element, or too few frames |

**High error on BOTH models almost always means multi-phase motion** (blur-in, then move, then
over-stretch, then settle). One curve can't fit that: split the timeline at the phase boundary
(read the frame index off the contact sheet), fit each segment by slicing `metrics.json`, then
compose them as a keyframe sequence with per-segment easing.

If error is high because the property barely moved, it won't appear at all: `progress()` drops
series with under ~1% range so you don't fit curves to noise.

Two more error inflators to rule out before splitting phases:

- **Wrong `--fps`**: `fit_curves.py` defaults to 30; if extraction used another rate, every
  `duration_ms` and stiffness is rescaled. Pass the extraction fps.
- **Duplicated frames**: runs of identical rows in `metrics.json` (over-sampled or
  variable-frame-rate source) plateau the progress curve and raise error on both models.
  Re-extract at the source rate or re-record.

## Asymmetric open/close

Open and close are almost never mirror images: fit each direction as its own clip and report two curves, never one curve reused reversed. Full treatment (why, and expressing it per target) in `references/choreography.md`.

## Converting spring params across APIs

The fit fixes `mass = 1`. From `stiffness` (k), `damping` (c), `mass` (m):

- **Motion / Framer Motion**: pass `stiffness`, `damping`, `mass` into
  `transition: { type: "spring", stiffness, damping, mass }`.
- **SwiftUI**: `Spring(mass:stiffness:damping:)`, or approximate with
  `.spring(response:, dampingFraction:)` where `response = 2π·√(m/k)` and
  `dampingFraction = c / (2·√(k·m))` (that's `zeta`).
- **Reanimated**: `withSpring(to, { stiffness, damping, mass })`.
- **CSS**: no native spring. Use the fitted `bezier.css`, or generate a `linear()` easing by
  sampling the spring response (more faithful for overshoot). Read `references/code-output.md`.
references/decision-framework.md
# Animation Decision Framework

## Contents
- [1. Should this animate at all?](#1-should-this-animate-at-all)
- [2. What is the purpose?](#2-what-is-the-purpose)
- [3. What easing should it use?](#3-what-easing-should-it-use)
- [4. How fast should it be?](#4-how-fast-should-it-be)

Answer these four questions in order before writing animation code.

## 1. Should this animate at all?

**How often will users see this animation?**

| Frequency | Examples | Decision |
|---|---|---|
| 100+ times/day | Keyboard shortcuts, command palette toggle | No animation. Ever. |
| Tens of times/day | Hover effects, list navigation | Remove or drastically reduce |
| Occasional | Modals, drawers, toasts | Standard animation |
| Rare / first-time | Onboarding, feedback forms, celebrations | Can add delight |

Never animate keyboard-initiated actions; they repeat hundreds of times daily and animation makes them feel slow and disconnected.

## 2. What is the purpose?

Answer "why does this animate?" before writing code.

| Purpose | Description | Example |
|---|---|---|
| **Feedback** | Confirms user action was received | Button scale on press, toggle state |
| **Orientation** | Shows spatial relationship | Drawer slides from edge, menu scales from trigger |
| **Continuity** | Preserves context across state changes | Page transitions, layout shifts |
| **Delight** | Adds personality (use sparingly) | Stagger reveals, spring overshoot |

If the purpose is just "it looks cool" and users see it often, don't animate.

## 3. What easing should it use?

Follow this decision tree:

- **Entering the viewport?** → enter curve: `cubic-bezier(0.22, 1, 0.36, 1)`
- **Exiting the viewport?** → same curve, shorter duration
- **Moving/sliding on screen?** → move curve: `cubic-bezier(0.25, 1, 0.5, 1)`
- **Simple hover (color/opacity)?** → `200ms ease`
- **Needs physics feel?** → spring
- **Direct manipulation (drag)?** → no easing, follow the pointer
- **Constant motion (marquee, spinner)?** → `linear`

Avoid `ease-in` for UI; it starts slow and feels sluggish. Built-in `ease-out`/`ease` have gentle acceleration that reads soft rather than decisive. Custom curves like `cubic-bezier(0.22, 1, 0.36, 1)` accelerate steeply (the element covers most of its distance in the first third), so the same 200ms feels significantly faster.

**Easing resources:** [easing.dev](https://easing.dev/) and [easings.co](https://easings.co/) for stronger custom variants.

For a full named cubic-bezier catalogue (ease-out-quad through ease-out-expo, symmetric variants), see [easing.dev](https://easing.dev/); SKILL.md ships the actionable defaults. Use weaker curves (quad, cubic) for small or frequent elements; stronger curves (quint, expo) for large or rare transitions.

### Asymmetric vs symmetric curves

Symmetric ease-in-out starts slow: a noticeable lag between the user's action and the element beginning to move. For interactive elements (drawers, panels, menus), use asymmetric curves, steep at the start and settling slowly, to preserve responsiveness while the slow deceleration adds quality.

Duration and easing are inseparable: a steep curve affords a longer duration because the movement is front-loaded. Vaul's drawer uses 500ms with `cubic-bezier(0.32, 0.72, 0, 1)` but doesn't feel slow, covering most of its distance in the first 200ms.

## 4. How fast should it be?

Pick duration from the easing defaults table in SKILL.md. Keep routine UI under 300ms; scale with distance: a full-screen menu can exceed 300ms, a 6px tooltip shift under 150ms.

### Perceived performance

Animation speed changes perceived performance:

- Fast-spinning spinner makes loading feel faster (same time, different perception)
- `ease-out` at 200ms _feels_ faster than `ease-in` at 200ms: user sees immediate movement
- Instant tooltips after the first opens (skip delay and animation) make the toolbar feel faster

### Asymmetric timing

Enter can be slightly slower than exit. Hold-to-delete: 2s linear on press, 200ms ease-out on release.

```css
/* Release: fast */
.overlay {
  transition: clip-path 200ms ease-out;
}

/* Press: slow and deliberate */
.button:active .overlay {
  transition: clip-path 2s linear;
}
```

### Instant enter, animated exit (productivity tools)

Restates SKILL.md's asymmetric-timing core rule for high-frequency ephemeral UI: enter instantly (0ms), exit with a brief fade (100-150ms).

```css
/* Hover highlight: instant appear, soft dismiss */
.highlight {
  transition: opacity 0.15s ease-out;
  opacity: 0;
}
.item:hover .highlight {
  transition-duration: 0s;
  opacity: 1;
}
```

Once the element should animate, match the UI pattern to a recipe via the "Transition decision rules" table in SKILL.md.
references/gesture-drag.md
# Gesture and Drag Animations

Drag, swipe, and gesture patterns where the user directly manipulates elements.

## Contents
- [Momentum-based dismissal](#momentum-based-dismissal)
- [Velocity handoff](#velocity-handoff)
- [Momentum projection](#momentum-projection)
- [Boundary damping](#boundary-damping)
- [Pointer capture](#pointer-capture)
- [Multi-touch protection](#multi-touch-protection)
- [Friction vs hard stops](#friction-vs-hard-stops)
- [Swipe-to-dismiss pattern](#swipe-to-dismiss-pattern)

## Momentum-based dismissal

Don't require dragging past a distance threshold; compute velocity at release so a quick flick dismisses.

```ts
function onPointerUp(e: PointerEvent) {
  const timeTaken = Date.now() - dragStartTime;
  const velocity = Math.abs(swipeAmount) / timeTaken;

  if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
    dismiss();
  } else {
    snapBack();
  }
}
```

Default threshold: velocity > 0.11. Combine with a minimum distance (e.g. 20px) to prevent accidental dismissals.

## Velocity handoff

When a gesture ends, the animation must continue at the finger's exact velocity so there is no visible seam between dragging and animating. This is the detail that most separates "fluid" from "fine". Pass the pointer's release velocity as the spring's initial velocity.

Motion and Framer Motion take absolute px/s velocity directly via the `velocity` option, so hand them the raw release velocity:

```ts
// releaseVelocity in px/s, measured over the last few pointermove events
animate(el, { y: target }, { type: "spring", velocity: releaseVelocity, bounce: 0, duration: 0.4 });
```

Some spring APIs want relative velocity: normalize by the remaining distance to the target.

```ts
const relativeVelocity = gestureVelocity / (targetValue - currentValue);
// element at y=50, target y=150 (100px to go), finger at 50px/s -> 50 / 100 = 0.5
```

To have velocity ready at release, track a short position and timestamp history (last few `pointermove` events), not just the current point.

## Momentum projection

Don't snap to the nearest boundary from the release point. Use velocity to project where the gesture is heading, then snap to the target nearest that projected point. This is what makes a flick feel like it throws the element, exactly like scroll deceleration. Good bottom sheets and carousels (Vaul, Embla) work this way.

```ts
// decelerationRate ~ 0.998 for a normal scroll feel; 0.99 for snappier
function project(initialVelocity: number, decelerationRate = 0.998): number {
  return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate);
}

const projectedEndpoint = currentPosition + project(releaseVelocity);
const target = nearestSnapPoint(projectedEndpoint); // choose target from the projection
animateSpringTo(target, { velocity: releaseVelocity }); // then hand off velocity (previous section)
```

Use this exponential-decay form, not the physics-textbook `v^2 / (2 * decel)`; the decay form is what Apple ships in the *Designing Fluid Interfaces* sample code.

## Boundary damping

Past the natural boundary (e.g. pulling a drawer up when already at top), apply damping: the more they drag, the less it moves.

```ts
function applyDamping(offset: number, max: number): number {
  return max * (1 - Math.exp(-offset / max));
}

// Usage: as offset grows, movement diminishes
const dampedOffset = applyDamping(rawOffset, 200);
```

Apple's canonical rubber-band function (from *Designing Fluid Interfaces*) is a good drop-in alternative, tuned to feel like iOS overscroll:

```ts
// the further past the bound, the less the element follows
function rubberband(overshoot: number, dimension: number, constant = 0.55): number {
  return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
}
```

Real things slow before stopping; friction beats hard stops.

## Pointer capture

On drag start, capture all pointer events so the drag continues even if the pointer leaves the element.

```ts
function onPointerDown(e: PointerEvent) {
  (e.target as HTMLElement).setPointerCapture(e.pointerId);
  isDragging = true;
}

function onPointerUp(e: PointerEvent) {
  (e.target as HTMLElement).releasePointerCapture(e.pointerId);
  isDragging = false;
}
```

Always use `setPointerCapture`; without it, fast swipes escape the element and the drag breaks.

## Multi-touch protection

Ignore extra touch points after the drag begins; without this, switching fingers mid-drag makes the element jump.

```ts
let activeTouchId: number | null = null;

function onPointerDown(e: PointerEvent) {
  if (activeTouchId !== null) return; // Ignore additional touches
  activeTouchId = e.pointerId;
  // Start drag...
}

function onPointerUp(e: PointerEvent) {
  if (e.pointerId !== activeTouchId) return;
  activeTouchId = null;
  // End drag...
}
```

## Friction vs hard stops

Allow drag past a boundary, with increasing friction:

```ts
function applyFriction(delta: number, isAtBoundary: boolean): number {
  if (!isAtBoundary) return delta;
  return delta * 0.3; // 30% of movement at boundary
}
```

Hard stops feel broken; users expect physics. Apply friction for scroll containers, sliders, and drawers.

## Swipe-to-dismiss pattern

Combine velocity, distance, and direction for a complete swipe gesture:

```ts
function handleSwipeEnd(direction: "left" | "right", distance: number, velocity: number) {
  const shouldDismiss = distance > THRESHOLD || velocity > 0.11;

  if (shouldDismiss) {
    // Animate out in swipe direction, handing off the release velocity (see Velocity handoff)
    animateOut(direction, velocity);
  } else {
    // Spring back to origin
    springBack();
  }
}
```

The exit should continue in the swipe direction with momentum; snapping elsewhere feels wrong. Feed `velocity` into the exit spring's `velocity` option so drag and animation share no seam.
references/measurement-guide.md
# Measurement Guide

What to measure in a recording, when to trust eyes vs scripts, and how to read
`track_motion.py` output.

## Contents

- Property checklist
- By eye vs scripted
- Reading metrics.json
- Choosing an ROI / bbox
- Pixel-tracking pitfalls

## Property checklist

Walk every animation against this list. Most "magic" hides in properties people forget:
opacity and blur lead the move, not position.

| Property | What it looks like | Field in metrics.json |
|---|---|---|
| Translate | Element changes position | `cx`, `cy` |
| Scale (per axis) | Box grows/shrinks, may over-stretch one axis | `width` → `scaleX`, `height` → `scaleY` |
| Opacity | Fades in/out, or a backdrop dims | `opacity` |
| Blur | Soft on entry, sharpens as it settles (or backdrop blur) | `blur` |
| Corner radius | Pill morphs to card; corners round/sharpen | `radius` |
| Shadow / elevation | Shadow grows as the element lifts | (measure by eye) |
| Color / fill | Background or tint shifts | (measure by eye) |

Anisotropic scale matters: a "fluid" morph usually stretches `height` ahead of `width` (or
vice versa), settling each independently. `fit_curves.py` fits `scaleX` (width) and `scaleY`
(height) separately for this reason. Compare them; don't assume uniform scale.

## By eye vs scripted

Reach for the contact sheet first; escalate to scripts only where precision pays off.

| Decide by eye | Measure with scripts |
|---|---|
| Which element moves, and the effect list | Exact per-frame position / size |
| Rough phase order (blur-in, then move, then settle) | Whether motion overshoots and by how much |
| Direction and origin of the motion | Spring vs bezier and its parameters |
| Whether open and close differ | Precise duration and per-property timing offsets |

A plain fade or linear slide needs no tracking: read the timing off the contact sheet and code
it. Spend the OpenCV/scipy budget on elastic, springy, or multi-property motion where
eyeballing is unreliable.

## Reading metrics.json

`track_motion.py` writes one record per frame:

```json
{ "frame": 7, "file": "frame_0008.png",
  "x": 38, "y": 96, "width": 318, "height": 360,
  "cx": 197.0, "cy": 276.0, "opacity": 0.82, "blur": 0.31, "radius": 0.44 }
```

- Frames before entry or after exit show `{"present": false}`: the element is absent, not an
  error. The summary line reports how many frames were skipped.
- All values are **pixel-derived proxies**, not ground truth: `opacity` is box fill-ratio,
  `blur` is inverse Laplacian sharpness, `radius` is empty-corner fraction. Treat them as
  *shapes over time* to fit against, not absolute CSS values.
- Watch the **trend**, not the absolute number. A `blur` falling 0.6 -> 0.1 over the first
  third tells you blur leads the move, whatever the exact figures.

## Choosing an ROI / bbox

Auto-detection diffs each frame against frame 1 and tracks the changed region. Works when one
element animates over a still backdrop. `--bbox X,Y,W,H` restricts that frame-diff detection
to the region; the element is still tracked *inside* it, so position, scale, and opacity stay
meaningful. Pass it when:

- Multiple things move and you want one (measure each separately, one bbox each).
- The backdrop also animates (e.g. a dimming overlay), polluting the diff.
- The element starts off-screen: pick the bbox where it ends up.

Use `--threshold` to loosen/tighten detection and `--invert` when the element is the *still*
part and the background moves. Read bbox coordinates straight off a frame.

## Pixel-tracking pitfalls

- **Dynamic Island / notch occlusion**: an element tucked under the island reads as a smaller
  box for the first frames. Expect clipped `height` early; trust later frames for true size.
- **Anti-aliasing & motion blur**: fast frames smear edges, inflating the `blur` proxy and
  softening the box. Sample at a higher `--fps` for very fast motion.
- **Drop shadows**: a soft shadow extends the box beyond the element. If `width` looks too
  large, tighten `--threshold` so faint shadow pixels fall below it.
- **Compression artifacts**: heavy compression adds diff noise; `MIN_AREA_FRAC` filters specks,
  but a high-bitrate recording always tracks cleaner.
references/performance-deep-dive.md
# Performance Deep Dive

Advanced performance guidance beyond the quick rules in SKILL.md.

## Contents
- [CSS vs JS animations](#css-vs-js-animations)
- [Web Animations API (WAAPI)](#web-animations-api-waapi)
- [CSS variables inheritance trap](#css-variables-inheritance-trap)
- [Motion transform ownership](#motion-transform-ownership)
- [Pause looping animations off-screen](#pause-looping-animations-off-screen)
- [Compositing layers and will-change](#compositing-layers-and-will-change)
- [Fix shaky 1px shifts](#fix-shaky-1px-shifts)

## CSS vs JS animations

| Approach | Driver | Interruptible | Best for |
|---|---|---|---|
| CSS transitions | Browser/compositor for transform/opacity | Yes (retargets) | Predetermined state changes |
| CSS keyframes | Browser/compositor when properties allow it | No (restarts from zero) | Looping, predetermined sequences |
| WAAPI (`el.animate()`) | Browser animation engine | Yes (cancel/reverse) | Dynamic values with imperative control |
| Motion values (`x`, `y`, `style`) | Motion DOM renderer, no React re-renders | Yes | React gestures, drag, coordinated UI |
| JS (`requestAnimationFrame`) | Main thread | Yes (manual) | Complex choreography, physics |

**Rule: CSS transitions > WAAPI > CSS keyframes > JS.** Under load (page navigation, heavy rendering), CSS stays smooth while JS drops frames.

## Web Animations API (WAAPI)

JavaScript control with CSS performance. Hardware-accelerated, interruptible, promise-based.

```ts
const animation = element.animate(
  [
    { transform: "translateY(100%)", opacity: 0 },
    { transform: "translateY(0)", opacity: 1 },
  ],
  {
    duration: 300,
    easing: "cubic-bezier(0.22, 1, 0.36, 1)",
    fill: "forwards",
  }
);

// Cancel or reverse at any time
animation.reverse();
await animation.finished;
```

## CSS variables inheritance trap

A CSS variable change on a parent recalculates styles for **all children**. In a drawer with many items, updating `--swipe-amount` on the container forces expensive recalc on every one.

```ts
// Bad: triggers recalc on all children
element.style.setProperty("--swipe-amount", `${distance}px`);

// Good: only affects this element
element.style.transform = `translateY(${distance}px)`;
```

Exception: `@property` with `inherits: false` avoids the cascade, but has limited browser support.

## Motion transform ownership

Motion's `x`/`y` are first-class APIs for single-axis movement and drag: they update without React re-renders and are the default for gesture-heavy components.

```tsx
const x = useMotionValue(0);

// Idiomatic Motion API for drag and axis movement
<motion.div drag="x" style={{ x }} />

// Use one handwritten transform string when you need to author
// multiple transform functions together or interop with non-Motion code
<motion.div animate={{ transform: "translateX(100px) rotate(4deg)" }} />
```

Don't mix Motion `x`/`y` props with a handwritten `transform` string on one element; pick one transform owner.

## Pause looping animations off-screen

Looping animations consume GPU resources even when not visible.

```ts
"use client";
import { useEffect, useRef } from "react";

export function usePauseOffscreen<T extends HTMLElement>() {
  const ref = useRef<T | null>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(([entry]) => {
      el.style.animationPlayState = entry.isIntersecting ? "running" : "paused";
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return ref;
}
```

## Compositing layers and will-change

`will-change` creates a new compositor layer, at a memory cost.

- Only promote during animation, remove after
- Only for `transform` and `opacity`
- Too many layers is worse than no promotion

```css
.animating { will-change: transform, opacity; }
```

Toggle the class on animation start, remove on `transitionend` or `animationend`.

## Fix shaky 1px shifts

Elements can shift 1px at animation start/end from GPU/CPU handoff. Apply `will-change: transform` during the animation (not permanently) to keep compositing on the GPU throughout.
references/review-format.md
# Animation Review Format

## Contents
- [Operating posture](#operating-posture)
- [Ten non-negotiable standards](#ten-non-negotiable-standards)
- [Remedial preference hierarchy](#remedial-preference-hierarchy)
- [Before/After/Why table](#beforeafterwhy-table)
- [Review checklist](#review-checklist)
- [Verdict output](#verdict-output)

## Operating posture

Senior motion reviewer with a brutal eye for craft. Bias toward motion that feels right, not motion that merely runs. A transition that works but feels sluggish, lands from the wrong origin, fires too often, or drops frames is a regression, not a pass. Default to flagging; approval is earned, not assumed.

## Ten non-negotiable standards

Measure every animation in the diff against these; a violation is a finding. For exact values (curves, durations, spring config), cite the easing/duration tables in `SKILL.md` rather than approximating. Each standard ends with a **Flag on sight** clause: hard findings to catch without deliberation.

1. **Justified motion.** Every animation answers "why animate this?": feedback, orientation, continuity, state, or deliberate delight. "Looks cool" on a frequently-seen element is a block.
2. **Frequency-appropriate.** Keyboard-initiated and 100+/day actions get no animation; tens/day gets reduced motion; occasional gets standard; rare or first-time can carry delight. Flag on sight: animation on a keyboard shortcut, command-palette toggle, or 100+/day action.
3. **Responsive easing.** Entering/exiting elements use `ease-out` or a strong custom curve; built-in CSS easings are too weak for deliberate animation. Flag on sight: `ease-in` on any UI interaction, or weak built-in easing on a deliberate animation (it delays the moment the user watches most).
4. **Sub-300ms UI.** UI animations stay under 300ms; scale duration with distance traveled. Flag on sight: UI duration > 300ms with no stated reason.
5. **Origin and physical correctness.** Popovers, dropdowns, and tooltips scale from their trigger (`transform-origin`), not center; modals stay centered. Flag on sight: `transform-origin: center` on a trigger-anchored popover/dropdown/tooltip, or `scale(0)`/pure-fade entrances with no initial transform (start at `scale(0.85-0.97)` plus opacity).
6. **Interruptibility.** Rapidly-triggered or gesture-driven motion (toasts, toggles, drags) must retarget from its current state; prefer CSS transitions or springs over keyframes, which restart from zero. Flag on sight: keyframes on toasts, toggles, or anything added/triggered rapidly.
7. **GPU-only properties.** Animate `transform` and `opacity` only. Flag on sight: animating `width`/`height`/`margin`/`padding`/`top`/`left`; `transition: all` (unbounded property animation); Framer Motion `x`/`y`/`scale` props on motion that runs while the page is busy; updating a CSS variable on a parent to drive a child transform (style recalc storm).
8. **Accessibility.** `prefers-reduced-motion` is honored (gentler, not zero: keep opacity/color, drop movement); hover animations gated behind `@media (hover: hover) and (pointer: fine)`. Flag on sight: missing reduced-motion handling on movement, or ungated `:hover` motion.
9. **Asymmetric enter/exit.** Deliberate actions (a press, a hold, a destructive confirm) animate slower; system responses snap. Flag on sight: symmetric enter/exit timing on a press-and-release or hold interaction.
10. **Cohesion.** Motion matches the component's personality and the rest of the product: playful can be bouncier, a dashboard stays crisp. When unsure whether motion feels right, the strongest move is often to delete it. Flag on sight: mismatched personality, a jarring crossfade where a subtle blur would bridge two states, or an everything-at-once entrance where a 30-50ms stagger belongs.

## Remedial preference hierarchy

Prefer earlier moves over later ones:

1. **Delete the animation** (high-frequency, no purpose, or keyboard-triggered).
2. **Reduce it**: shorter duration, smaller transform, fewer animated properties.
3. **Fix the easing**: swap `ease-in` to `ease-out` or a strong custom curve.
4. **Fix the origin and physicality**: correct `transform-origin`; replace `scale(0)` with `scale(0.95)` plus opacity.
5. **Make it interruptible**: keyframes to transitions, or a spring for gesture-driven motion.
6. **Move it to the GPU**: layout props to `transform`/`opacity`; shorthand to a full `transform` string; WAAPI for programmatic CSS.
7. **Asymmetric timing**: slow the deliberate phase, snap the response.
8. **Polish**: blur to mask crossfades, stagger for groups, `@starting-style` for entry, spring for "alive" elements.
9. **Accessibility and cohesion**: add reduced-motion and hover gating; tune to match the component's personality.

## Before/After/Why table

Required first part of every review. Markdown table, one row per issue; never a "Before:/After:" list on separate lines.

| Before | After | Why |
|---|---|---|
| `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; `all` animates unintended properties off-GPU |
| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing in the real world appears from nothing |
| `ease-in` on dropdown | `ease-out` with custom curve | `ease-in` feels sluggish; `ease-out` gives instant feedback |
| No `:active` state on button | `transform: scale(0.97)` on `:active` | Buttons must feel responsive to press |
| `transform-origin: center` on popover | `transform-origin: var(--radix-popover-content-transform-origin)` | Popovers scale from trigger (modals stay centered) |

## Review checklist

Rows add recipe-specific signal beyond the ten standards; for the standard violations (`transition: all`, layout props, `ease-in`, `scale(0)`, hover guard, symmetric timing, keyboard action, >300ms, rapid-fire keyframes) see the Flag-on-sight clauses above.

| Issue | Fix |
|---|---|
| CSS variable drag animation | Use `transform` directly on the element |
| Missing `setPointerCapture` on drag | Add pointer capture for reliable tracking |
| Motion `x`/`y` mixed with a handwritten `transform` | Pick one transform owner |
| Hard cut between views sharing elements | Add shared-element transition; animate persistent components in place |
| Contextual overlay enters from centre | Set `transform-origin` to trigger; animate outward from source |
| Elements all appear at once | Add stagger delay (30-50ms between items) |
| Touch target under 44px on interactive element | Add `::before` pseudo-element sized to 44x44px minimum (WCAG 2.5.5) |
| Hover scale > 1.03 or hover duration > 150ms | Use `scale(1.01-1.02)` and 100-150ms transition |
| Container animates AND children stagger | Pick one entrance: animate the container OR stagger children, not both |
| Missing close-state cleanup after `setTimeout` | Add `is-closing` class, remove after transition duration |
| Missing reflow (`void el.offsetWidth`) between class changes | Force reflow before re-adding classes to restart transitions |
| Animating container instead of inner pieces | Apply transitions to child elements, not the wrapper |
| Hardcoded `stroke-dasharray` on SVG success path | Use `path.getTotalLength()` to measure the path |
| `.is-error` and `.is-shaking` merged into one class | Keep them separate: `.is-shaking` controls animation only, `.is-error` controls visual state |

## Verdict output

Required second part of every review. Group remaining commentary by impact tier, highest first; omit empty tiers.

1. **Feel-breaking regressions**: sluggish easing, comes-from-nowhere entrances, motion on high-frequency or keyboard actions.
2. **Missed simplifications**: animations to remove or drastically reduce.
3. **Performance**: non-GPU properties, dropped-frame risks, recalc storms.
4. **Interruptibility and timing**: keyframes where transitions/springs belong; symmetric timing that should be asymmetric.
5. **Origin, physicality, and cohesion**: wrong origin, mismatched personality, jarring crossfades.
6. **Accessibility**: reduced-motion and pointer/hover gating.

Close with a decision, citing `file:line`:

- **Block**: any feel-breaking regression, animation on a keyboard or high-frequency action, `scale(0)` or `ease-in` on UI, or a non-GPU animation with an easy GPU fix.
- **Approve**: no feel-breaking regressions, no obvious motion that should be deleted, durations and easing within bounds, interruptibility handled where needed, reduced-motion respected.

Reusable-component library DX (defaults over options, drop-in ergonomics, naming, docs site) is authoring, not review; see the `ui-design` skill.

For debugging animations (slow-motion, DevTools Animations panel, real-device testing, next-day review), see the Validation section in `SKILL.md`.
references/spring-animations.md
# Spring Animations

Springs simulate physics, so they feel more natural than duration-based animations: no fixed duration, they settle by physical parameters.

## Contents
- [When to use springs](#when-to-use-springs)
- [Spring parameters](#spring-parameters)
- [Configuration presets](#configuration-presets)
- [Apple's damping and response framing](#apples-damping-and-response-framing)
- [Interruptibility advantage](#interruptibility-advantage)
- [Spring-based mouse interactions](#spring-based-mouse-interactions)
- [Snap instead of spring](#snap-instead-of-spring)

## When to use springs

- Drag with momentum (release, let physics take over)
- Elements that feel "alive" (Apple's Dynamic Island)
- Gestures interruptible mid-animation
- Decorative mouse-tracking interactions
- Overshoot effects (playful UI)

**Don't use springs for:** simple fades, color transitions, or precise-timing UI.

## Spring parameters

| Parameter | What it controls | Typical range |
|---|---|---|
| `stiffness` | Speed of movement (higher = faster) | 100-500 |
| `damping` | Resistance (lower = more bounce) | 15-40 |
| `mass` | Weight feel (higher = slower, heavier) | 0.5-2 |

## Configuration presets

**Apple-style (recommended, easier to reason about):**

```js
{ type: "spring", duration: 0.5, bounce: 0.2 }
```

**Traditional physics (more control):**

| Preset | stiffness | damping | Use case |
|---|---|---|---|
| Snappy (Apple default) | 500 | 40 | General UI, no bounce |
| Bouncy | 300 | 20 | Playful elements, notifications |
| Gentle | 200 | 30 | Page transitions, large elements |
| Stiff | 700 | 50 | Small precise movements |

Bounce signals brand personality. Default to zero (the safe choice): a finance dashboard should never bounce; a learning app or creative tool can use subtle bounce (0.1-0.2) to feel friendlier. The question isn't "does it look better with bounce?" but "does it match the brand?"

## Apple's damping and response framing

Apple deliberately replaced the physics triplet (mass/stiffness/damping) with two designer-friendly parameters. Reason in these:

- **Damping ratio** controls overshoot. `1.0` = critically damped, no bounce, smooth settle; `< 1.0` overshoots and oscillates; lower = bouncier.
- **Response** is how quickly the value reaches the target, in seconds. Lower = snappier. This is not a duration: a spring has no fixed duration, its settle time emerges from the parameters.

Default most UI to **damping 1.0** (critically damped): graceful and non-distracting. Add bounce (**damping ~0.8**) only when the gesture itself carried momentum (a flick, a throw, a drag release). Overshoot on a menu that just faded in feels wrong; overshoot on a card you flicked feels right.

Values Apple ships:

| Interaction | Damping | Response |
|---|---|---|
| Move / reposition (e.g. PiP) | `1.0` | `0.4` |
| Rotation | `0.8` | `0.4` |
| Drawer / sheet | `0.8` | `0.3` |

**Web mapping:** Motion's `bounce` + `duration` spring API maps closely to Apple's damping + response. A safe house style is critically damped springs everywhere by default; reserve bounce for momentum-driven, physical interactions.

```js
// Critically damped default (no overshoot)
animate(el, { y: 0 }, { type: "spring", bounce: 0, duration: 0.4 });

// Momentum interaction: a little bounce, only because a flick preceded it
animate(el, { y: target }, { type: "spring", bounce: 0.2, duration: 0.4 });
```

## Interruptibility advantage

Springs keep velocity when interrupted; CSS keyframes restart from zero. Ideal for gestures users might change mid-motion.

```tsx
// Spring reverses smoothly from current position
<motion.div
  animate={{ transform: isOpen ? "translateX(0)" : "translateX(-100%)" }}
  transition={{ type: "spring", stiffness: 500, damping: 40 }}
/>
```

Three rules make interruption feel seamless:

- **Animate from the presentation value, never the logical target.** On interrupt, read the element's live on-screen transform and start the new animation from there. Starting from the target value causes a visible jump. (A closing modal the user grabs again should follow the finger, not finish closing first and then reopen.) Springs do this by default; CSS transitions and keyframes cannot be grabbed and reversed mid-flight, so avoid them for gesture-driven motion.
- **Carry velocity through a retarget.** Replacing one animation with another at a reversal creates a velocity discontinuity, a "brick wall". Pick a spring library that re-targets from the current velocity (iOS does this natively with additive animations).
- **Decompose 2D motion into independent X and Y springs.** A single spring on a 2D distance desyncs when X and Y have different velocities.

## Spring-based mouse interactions

Tying values directly to mouse position feels artificial. Use `useSpring` to interpolate instead of updating immediately.

```tsx
import { useSpring } from "framer-motion";

// Without spring: instant, feels artificial
const rotation = mouseX * 0.1;

// With spring: has momentum, feels natural
const springRotation = useSpring(mouseX * 0.1, {
  stiffness: 100,
  damping: 10,
});
```

Only for **decorative** interactions. On a functional graph in a banking app, no animation is better.

## Snap instead of spring

If the interaction needs instant response or precise timing, skip the spring: use a short transition or snap to the end state.

```tsx
<motion.div
  animate={{ opacity: isOpen ? 1 : 0, x: isOpen ? 0 : -12 }}
  transition={
    shouldSnap
      ? { duration: 0.12, ease: "linear" }
      : { type: "spring", stiffness: 500, damping: 40 }
  }
/>
```
references/transition-recipes.md
# CSS Transition Recipes

12 CSS transition patterns. Each includes CSS, HTML hooks, JS orchestration where needed, and a `prefers-reduced-motion` guard. All read from a shared `:root` custom properties block.

## Contents

- [Custom properties](#custom-properties)
- [Card resize](#card-resize)
- [Panel reveal](#panel-reveal)
- [Notification badge](#notification-badge)
- [Icon swap](#icon-swap)
- [Menu dropdown](#menu-dropdown)
- [Modal dialog](#modal-dialog)
- [Text state swap](#text-state-swap)
- [Page side-by-side slides](#page-side-by-side-slides)
- [Number pop-in](#number-pop-in)
- [Avatar group hover](#avatar-group-hover)
- [Success celebration](#success-celebration)
- [Error state shake](#error-state-shake)

---

## Custom properties

Add this `:root` block once to your global stylesheet; every recipe reads these names.

```css
:root {
  /* Card resize */
  --resize-dur: 300ms;
  --resize-ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* Number pop-in */
  --digit-dur: 500ms;
  --digit-dist: 12px;
  --digit-stagger: 70ms;
  --digit-blur: 6px;
  --digit-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --digit-dir-x: 0;
  --digit-dir-y: 1;

  /* Notification badge */
  --badge-slide-dur: 260ms;
  --badge-pop-dur: 500ms;
  --badge-blur: 2px;
  --badge-offset-x: -8px;
  --badge-offset-y: 12px;
  --badge-ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* Text state swap */
  --text-swap-dur: 150ms;
  --text-swap-y: 4px;
  --text-swap-blur: 2px;
  --text-swap-ease: ease-in-out;

  /* Menu dropdown */
  --dropdown-open-dur: 250ms;
  --dropdown-close-dur: 150ms;
  --dropdown-pre-scale: 0.96;
  --dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* Modal dialog */
  --modal-open-dur: 250ms;
  --modal-close-dur: 150ms;
  --modal-scale: 0.96;
  --modal-ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* Panel reveal */
  --panel-open-dur: 400ms;
  --panel-close-dur: 350ms;
  --panel-translate-y: 12px;
  --panel-blur: 4px;
  --panel-ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* Page side-by-side */
  --page-dur: 200ms;
  --page-dist: 8px;
  --page-blur: 3px;
  --page-stagger: 60ms;
  --page-exit-enabled: 1;
  --page-ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* Icon swap */
  --icon-swap-dur: 200ms;
  --icon-swap-blur: 2px;
  --icon-swap-start-scale: 0.25;
  --icon-swap-ease: ease-in-out;

  /* Success celebration */
  --success-opacity-dur: 550ms;
  --success-rotate-dur: 550ms;
  --success-bob-dur: 550ms;
  --success-blur-dur: 400ms;
  --success-path-dur: 550ms;
  --success-path-delay: 80ms;
  --success-rotate-from: 80deg;
  --success-rotate-to: 0deg;
  --success-bob-y: 40px;
  --success-blur-from: 10px;
  --success-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --success-bob-ease: cubic-bezier(0.34, 3.85, 0.64, 1);

  /* Avatar group hover */
  --avatar-lift: -4px;
  --avatar-dur: 320ms;
  --avatar-scale: 1.05;
  --avatar-falloff: 0.45;
  --avatar-ease-in: cubic-bezier(0.22, 1, 0.36, 1);
  --avatar-ease-out: cubic-bezier(0.34, 3.85, 0.64, 1);

  /* Error state shake */
  --shake-dist: 4px;
  --shake-overshoot: 2px;
  --shake-dur-1: 80ms;
  --shake-dur-2: 80ms;
  --shake-dur-3: 60ms;
  --shake-ease: cubic-bezier(0.36, 0.07, 0.19, 0.97);
  --shake-revert-dur: 200ms;
  --shake-hold: 1200ms;
}
```

---

## Card resize

Tween a container's width or height when its layout state changes (compact/expanded card, collapsing panel, list row toggling detail). CSS only, no JS.

```html
<div class="t-resize">Content</div>
```

```css
.t-resize {
  transition: width var(--resize-dur) var(--resize-ease),
              height var(--resize-dur) var(--resize-ease);
  will-change: width, height;
  overflow: hidden;
}

@media (prefers-reduced-motion: reduce) {
  .t-resize { transition: none; }
}
```

Toggle dimensions with a state class or inline style; the transition handles the tween.

---

## Panel reveal

Slide a panel into an existing container with cross-blur. CSS only, toggle `data-open`.

See also: `component-patterns.md` § Drawers and panels for percentage-based drawer slides.

```html
<div class="t-panel" data-open="false">Panel content</div>
```

```css
.t-panel {
  opacity: 0;
  transform: translateY(var(--panel-translate-y));
  filter: blur(var(--panel-blur));
  transition: opacity var(--panel-open-dur) var(--panel-ease),
              transform var(--panel-open-dur) var(--panel-ease),
              filter var(--panel-open-dur) var(--panel-ease);
}
.t-panel[data-open="true"] {
  opacity: 1;
  transform: translateY(0);
  filter: blur(0);
}
.t-panel[data-open="false"] {
  transition-duration: var(--panel-close-dur);
}

@media (prefers-reduced-motion: reduce) {
  .t-panel { transition: none; }
}
```

---

## Notification badge

Slide a small badge onto a trigger (button, icon) and pop the dot; the trigger stays put. CSS only, toggle `data-open`.

```html
<button style="position: relative">
  Inbox
  <span class="t-badge" data-open="false">
    <span class="t-badge-dot"></span>
  </span>
</button>
```

```css
.t-badge {
  position: absolute;
  opacity: 0;
  transform: translate(var(--badge-offset-x), var(--badge-offset-y));
  filter: blur(var(--badge-blur));
  transition: opacity var(--badge-slide-dur) var(--badge-ease),
              transform var(--badge-slide-dur) var(--badge-ease),
              filter var(--badge-slide-dur) var(--badge-ease);
}
.t-badge[data-open="true"] {
  opacity: 1;
  transform: translate(0, 0);
  filter: blur(0);
}
.t-badge-dot {
  display: block;
  width: 8px; height: 8px;
  border-radius: 50%;
  background: currentColor;
  transform: scale(0);
  transition: transform var(--badge-pop-dur) var(--badge-ease);
}
.t-badge[data-open="true"] .t-badge-dot {
  transform: scale(1);
  transition-delay: calc(var(--badge-slide-dur) * 0.5);
}

@media (prefers-reduced-motion: reduce) {
  .t-badge, .t-badge-dot { transition: none; animation: none; }
}
```

---

## Icon swap

Cross-fade two icons in one slot (hamburger/close, play/pause). CSS grid stacks both. Toggle `data-state`.

See also: `contextual-animations.md` § Contextual icon swaps for the Motion/AnimatePresence approach.

```html
<span class="t-icon-swap" data-state="a">
  <span class="t-icon" data-icon="a">☰</span>
  <span class="t-icon" data-icon="b">✕</span>
</span>
```

```css
.t-icon-swap {
  display: inline-grid;
}
.t-icon {
  grid-area: 1 / 1;
  opacity: 0;
  transform: scale(var(--icon-swap-start-scale));
  filter: blur(var(--icon-swap-blur));
  transition: opacity var(--icon-swap-dur) var(--icon-swap-ease),
              transform var(--icon-swap-dur) var(--icon-swap-ease),
              filter var(--icon-swap-dur) var(--icon-swap-ease);
}
.t-icon-swap[data-state="a"] [data-icon="a"],
.t-icon-swap[data-state="b"] [data-icon="b"] {
  opacity: 1;
  transform: scale(1);
  filter: blur(0);
}

@media (prefers-reduced-motion: reduce) {
  .t-icon { transition: none; }
}
```

---

## Menu dropdown

Origin-aware dropdown with open/close animations. JS handles close-state cleanup.

See also: `component-patterns.md` § Popovers and dropdowns for Radix UI transform-origin and scale patterns.

```html
<div class="t-dropdown" data-origin="top-left">
  Menu content
</div>
```

```css
.t-dropdown {
  opacity: 0;
  transform: scale(var(--dropdown-pre-scale));
  transition: opacity var(--dropdown-open-dur) var(--dropdown-ease),
              transform var(--dropdown-open-dur) var(--dropdown-ease);
}
.t-dropdown.is-open {
  opacity: 1;
  transform: scale(1);
}
.t-dropdown.is-closing {
  opacity: 0;
  transform: scale(0.99);
  transition-duration: var(--dropdown-close-dur);
}

.t-dropdown[data-origin="top-left"]     { transform-origin: top left; }
.t-dropdown[data-origin="top-center"]   { transform-origin: top center; }
.t-dropdown[data-origin="top-right"]    { transform-origin: top right; }
.t-dropdown[data-origin="bottom-left"]  { transform-origin: bottom left; }
.t-dropdown[data-origin="bottom-center"]{ transform-origin: bottom center; }
.t-dropdown[data-origin="bottom-right"] { transform-origin: bottom right; }

@media (prefers-reduced-motion: reduce) {
  .t-dropdown { transition: none; }
}
```

**JS, close with cleanup:**

```js
function closeDropdown(el) {
  el.classList.add("is-closing");
  el.classList.remove("is-open");
  const dur = parseFloat(getComputedStyle(el).getPropertyValue("--dropdown-close-dur"));
  setTimeout(() => el.classList.remove("is-closing"), dur);
}
```

---

## Modal dialog

Scale-up modal with softer scale-down on close. Class-based state.

See also: `component-patterns.md` § Modals and dialogs for `@starting-style` entry pattern.

```html
<div class="t-modal" role="dialog">Modal content</div>
```

```css
.t-modal {
  opacity: 0;
  transform: scale(var(--modal-scale));
  transform-origin: center;
  transition: opacity var(--modal-open-dur) var(--modal-ease),
              transform var(--modal-open-dur) var(--modal-ease);
}
.t-modal.is-open {
  opacity: 1;
  transform: scale(1);
}
.t-modal.is-closing {
  opacity: 0;
  transform: scale(var(--modal-scale));
  transition-duration: var(--modal-close-dur);
}

@media (prefers-reduced-motion: reduce) {
  .t-modal { transition: none; }
}
```

**JS, close with cleanup:**

```js
function closeModal(el) {
  el.classList.add("is-closing");
  el.classList.remove("is-open");
  const dur = parseFloat(getComputedStyle(el).getPropertyValue("--modal-close-dur"));
  setTimeout(() => el.classList.remove("is-closing"), dur);
}
```

---

## Text state swap

Swap text in place with a blurred vertical transition ("Processing..." → "Done"). JS coordinates the three phases.

```html
<span class="t-text-swap">Processing...</span>
```

```css
.t-text-swap {
  display: inline-block;
  transition: opacity var(--text-swap-dur) var(--text-swap-ease),
              transform var(--text-swap-dur) var(--text-swap-ease),
              filter var(--text-swap-dur) var(--text-swap-ease);
}
.t-text-swap.is-exit {
  opacity: 0;
  transform: translateY(calc(-1 * var(--text-swap-y)));
  filter: blur(var(--text-swap-blur));
}
.t-text-swap.is-enter-start {
  opacity: 0;
  transform: translateY(var(--text-swap-y));
  filter: blur(var(--text-swap-blur));
}

@media (prefers-reduced-motion: reduce) {
  .t-text-swap { transition: none; }
}
```

**JS, three-phase orchestration:**

```js
function swapText(el, newText) {
  const dur = parseFloat(getComputedStyle(el).getPropertyValue("--text-swap-dur"));
  el.classList.add("is-exit");
  setTimeout(() => {
    el.textContent = newText;
    el.classList.remove("is-exit");
    el.classList.add("is-enter-start");
    void el.offsetWidth; // force reflow
    el.classList.remove("is-enter-start");
  }, dur);
}
```

---

## Page side-by-side slides

Slide between two adjacent pages (list/detail, wizard steps). Page 1 exits left, page 2 enters right.

See also: `component-patterns.md` § Step form navigation for the Motion/AnimatePresence approach with direction variants.

```html
<div class="t-page-slide" data-page="1">
  <section data-page-id="1">Page 1</section>
  <section data-page-id="2">Page 2</section>
</div>
```

```css
.t-page-slide {
  display: grid;
  overflow: hidden;
}
.t-page-slide > * {
  grid-area: 1 / 1;
  transition: opacity var(--page-dur) var(--page-ease),
              transform var(--page-dur) var(--page-ease),
              filter var(--page-dur) var(--page-ease);
}

/* Page 1 active */
.t-page-slide[data-page="1"] [data-page-id="1"] {
  opacity: 1; transform: translateX(0); filter: blur(0);
}
.t-page-slide[data-page="1"] [data-page-id="2"] {
  opacity: 0;
  transform: translateX(var(--page-dist));
  filter: blur(var(--page-blur));
}

/* Page 2 active */
.t-page-slide[data-page="2"] [data-page-id="2"] {
  opacity: 1; transform: translateX(0); filter: blur(0);
}
.t-page-slide[data-page="2"] [data-page-id="1"] {
  opacity: 0;
  transform: translateX(calc(-1 * var(--page-dist)));
  filter: blur(var(--page-blur));
}

@media (prefers-reduced-motion: reduce) {
  .t-page-slide > * { transition: none; }
}
```

**JS, switch page:**

```js
slider.setAttribute("data-page", String(n));
```

Set `--page-exit-enabled: 0` for fade-only, no slide (useful on initial load).

---

## Number pop-in

Re-enter digits with directional blur on number update (counters, prices, balances). Each digit animates individually with optional stagger.

```html
<span class="t-digits">
  <span class="t-digit">1</span>
  <span class="t-digit">2</span>
  <span class="t-digit" data-stagger="1">3</span>
  <span class="t-digit" data-stagger="2">4</span>
</span>
```

```css
.t-digits {
  display: inline-flex;
}

@keyframes digit-enter {
  from {
    opacity: 0;
    transform: translate(
      calc(var(--digit-dir-x) * var(--digit-dist)),
      calc(var(--digit-dir-y) * var(--digit-dist))
    );
    filter: blur(var(--digit-blur));
  }
}

.t-digit {
  display: inline-block;
  animation: digit-enter var(--digit-dur) var(--digit-ease) both;
}
.t-digit[data-stagger="1"] { animation-delay: var(--digit-stagger); }
.t-digit[data-stagger="2"] { animation-delay: calc(var(--digit-stagger) * 2); }

@media (prefers-reduced-motion: reduce) {
  .t-digit { animation: none; }
}
```

**JS, replay on update:**

```js
function updateDigits(container, newValue) {
  container.classList.remove("is-animating");
  container.innerHTML = String(newValue)
    .split("")
    .map((d, i, arr) => {
      const stagger = i >= arr.length - 2 ? ` data-stagger="${arr.length - 1 - i}"` : "";
      return `<span class="t-digit"${stagger}>${d}</span>`;
    })
    .join("");
  void container.offsetWidth; // force reflow
  container.classList.add("is-animating");
}
```

---

## Avatar group hover

Distance-falloff lift on a horizontal stack. Hovered item lifts and scales; neighbors lift less with distance. Bouncy spring on leave.

```html
<div class="t-avatar-group">
  <div class="t-avatar">A</div>
  <div class="t-avatar">B</div>
  <div class="t-avatar">C</div>
</div>
```

```css
.t-avatar-group {
  display: flex;
  gap: 4px;
}
.t-avatar {
  transition: transform var(--avatar-dur) var(--avatar-ease-in);
}

@media (prefers-reduced-motion: reduce) {
  .t-avatar { transition: none; }
}
```

**JS, distance-based lift:**

Set `transition-timing-function` inline *before* writing CSS variables. The browser applies whatever timing function is current when the property changes, giving smooth ease-in on hover and bouncy ease-out on return without separate declarations.

```js
const group = document.querySelector(".t-avatar-group");
const items = [...group.querySelectorAll(".t-avatar")];
const lift = parseFloat(getComputedStyle(group).getPropertyValue("--avatar-lift"));
const scale = parseFloat(getComputedStyle(group).getPropertyValue("--avatar-scale"));
const falloff = parseFloat(getComputedStyle(group).getPropertyValue("--avatar-falloff"));

group.addEventListener("mouseenter", (e) => {
  const target = e.target.closest(".t-avatar");
  if (!target) return;
  const idx = items.indexOf(target);
  items.forEach((item, i) => {
    const dist = Math.abs(i - idx);
    item.style.transitionTimingFunction = "var(--avatar-ease-in)";
    if (dist === 0) {
      item.style.transform = `translateY(${lift}px) scale(${scale})`;
    } else {
      const y = lift * Math.pow(falloff, dist);
      item.style.transform = `translateY(${y}px)`;
    }
  });
}, true);

group.addEventListener("mouseleave", () => {
  items.forEach((item) => {
    item.style.transitionTimingFunction = "var(--avatar-ease-out)";
    item.style.transform = "";
  });
});
```

---

## Success celebration

Multi-layered success: fade, rotation, blur reduction, Y-bob with overshoot, optional SVG stroke draw. Toggle `data-state` to `"in"`.

```html
<div class="t-success" data-state="out">
  <svg><path class="t-success-path" d="..." /></svg>
</div>
```

```css
.t-success {
  opacity: 0;
  transform: rotate(var(--success-rotate-from)) translateY(var(--success-bob-y));
  filter: blur(var(--success-blur-from));
}

@keyframes success-in {
  0% {
    opacity: 0;
    transform: rotate(var(--success-rotate-from)) translateY(var(--success-bob-y));
    filter: blur(var(--success-blur-from));
  }
  100% {
    opacity: 1;
    transform: rotate(var(--success-rotate-to)) translateY(0);
    filter: blur(0);
  }
}

.t-success[data-state="in"] {
  animation: success-in var(--success-opacity-dur) var(--success-ease) forwards;
}

.t-success-path {
  stroke-dashoffset: var(--path-length);
  stroke-dasharray: var(--path-length);
  transition: stroke-dashoffset var(--success-path-dur) var(--success-ease);
  transition-delay: var(--success-path-delay);
}
.t-success[data-state="in"] .t-success-path {
  stroke-dashoffset: 0;
}

@media (prefers-reduced-motion: reduce) {
  .t-success { animation: none; opacity: 1; transform: none; filter: none; }
  .t-success-path { transition: none; stroke-dashoffset: 0; }
}
```

**JS, set path length and replay:**

Never hardcode `stroke-dasharray`. Use `getTotalLength()` to measure the actual path.

```js
function playSuccess(el) {
  const path = el.querySelector(".t-success-path");
  if (path) {
    const len = path.getTotalLength();
    el.style.setProperty("--path-length", len);
  }
  el.setAttribute("data-state", "out");
  void el.offsetWidth; // force reflow to restart keyframes
  el.setAttribute("data-state", "in");
}
```

---

## Error state shake

Per-segment shake with auto-reverting error border. Three classes: `.is-error` on wrapper and input, `.is-shaking` on input only.

```html
<div class="t-error-wrap">
  <input class="t-error-input" />
  <p class="t-error-msg">Invalid email</p>
</div>
```

```css
@keyframes shake {
  0%   { transform: translateX(0); }
  25%  { transform: translateX(var(--shake-dist)); }
  50%  { transform: translateX(calc(-1 * var(--shake-overshoot))); }
  75%  { transform: translateX(calc(var(--shake-dist) * 0.5)); }
  100% { transform: translateX(0); }
}

.t-error-input {
  transition: border-color var(--shake-revert-dur) ease;
}
.t-error-input.is-error {
  border-color: var(--color-error, #ef4444);
}
.t-error-input.is-shaking {
  animation: shake
    calc(var(--shake-dur-1) + var(--shake-dur-2) + var(--shake-dur-3))
    var(--shake-ease);
}

.t-error-msg {
  opacity: 0;
  transform: translateY(-4px);
  transition: opacity 150ms ease, transform 150ms ease;
}
.t-error-wrap.is-error .t-error-msg {
  opacity: 1;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  .t-error-input { animation: none; }
  .t-error-msg { transition: none; }
}
```

**JS, trigger and auto-revert:**

Keep `.is-error` and `.is-shaking` separate. `.is-shaking` controls only the shake animation, removed on `animationend`. `.is-error` controls the border colour and message visibility, auto-reverting after the hold duration.

```js
function triggerError(wrap, input) {
  wrap.classList.add("is-error");
  input.classList.add("is-error", "is-shaking");

  input.addEventListener("animationend", () => {
    input.classList.remove("is-shaking");
  }, { once: true });

  const hold = parseFloat(getComputedStyle(wrap).getPropertyValue("--shake-hold"));
  setTimeout(() => {
    wrap.classList.remove("is-error");
    input.classList.remove("is-error");
  }, hold);
}
```
references/vocabulary.md
# Animation Vocabulary

Reverse-lookup glossary: turn a vague description of a motion or effect into the precise term, so the user knows what to ask for.

## Contents
- [How to answer](#how-to-answer)
- [Examples](#examples)
- [Glossary](#glossary)

## How to answer

The user describes an effect loosely; you return the matching term(s) in this format:

```
**Stagger**: Animate several items one after another with a small delay between each, creating a cascade.
```

If several terms fit, lead with the best match, then 1-2 alternates with a one-line note on how they differ.

1. **Read for intent, not keywords.** Users describe what they see or feel ("springy", "slides off", "draws itself in"), not the technical name. Map the sensation to the glossary.
2. **Quote the glossary verbatim.** Its descriptions are authoritative; use them as-is.
3. **Disambiguate close terms.** When two compete (clip-path vs mask, pop in vs bounce, shared element transition vs layout animation), contrast them so the user can pick.
4. **When nothing matches exactly,** name the closest term and say plainly it's an approximation, or describe the effect in the glossary's vocabulary ("that's a stagger of scale-in entrances").
5. **Stay within this glossary.** If a term genuinely isn't here, say so rather than inventing one, though you may explain the concept using these words.
6. **Keep it tight.** A naming question wants a name, not an essay. Lead with the term; expand only if asked.

## Examples

**Feel-based**
User: "What's it called when a popover seems to grow out of the button you clicked instead of from its middle?"
Answer: **Origin-aware animation**: An element animates out of its trigger, like a popover growing from the button that opened it instead of from its own center, which is the default in CSS.

**Disambiguation**
User: "The thing where one image turns into another image."
Answer: **Morph**: One shape smoothly turns into another shape, e.g. Dynamic Island. Close alternates: **Crossfade** if they simply fade over each other in the same spot; **Shared element transition** if an element travels and transforms from one position into another.

**Physics feel**
User: "That iOS scroll where it resists and snaps back when you pull too far."
Answer: **Rubber-banding**: Resistance and snap-back when you drag past a boundary (the iOS overscroll feel).

## Glossary

### Entrances and exits: how elements appear and disappear
- **Fade in / Fade out**: Element appears or disappears by changing opacity.
- **Slide in**: Element enters by sliding in from off-screen (left, right, top, or bottom).
- **Scale in**: Element grows from smaller to full size as it appears, often paired with a fade.
- **Pop in**: Element appears with a slight overshoot, like it bounces into place.
- **Reveal**: Content is uncovered gradually, often by animating a clip-path or mask.
- **Enter / Exit**: The animation an element plays when it's added to or removed from the screen.

### Sequencing and timing: coordinating multiple elements or moments
- **Keyframes**: Defined points in an animation (0%, 50%, 100%) that the browser fills the gaps between.
- **Interpolation / Tween**: Generating all the in-between frames between a start and end value, so motion is continuous.
- **Stagger**: Animate several items one after another with a small delay between each, creating a cascade.
- **Orchestration**: Deliberately timing multiple animations so they feel like one coordinated motion.
- **Delay**: Time before an animation starts.
- **Duration**: How long an animation takes.
- **Fill mode**: Whether an element keeps its first or last frame's styles before the animation starts or after it ends (e.g. forwards).
- **Stepped animation**: An animation divided into discrete steps, like a countdown timer.

### Movement and transforms: changing an element's position, size, or angle
- **Translate**: Move an element along the X or Y axis.
- **Scale**: Make an element bigger or smaller.
- **Rotate**: Spin an element around a point.
- **Skew**: Slant an element along the X or Y axis, shearing it out of its rectangular shape.
- **3D tilt / Flip**: Rotate in 3D space (rotateX / rotateY) to add depth.
- **Perspective**: How strong the 3D effect looks; a lower value exaggerates depth, like the viewer is closer.
- **Transform origin**: The anchor point a scale or rotation grows or spins from.
- **Origin-aware animation**: An element animates out of its trigger, like a popover growing from the button that opened it instead of from its own center, which is the default in CSS.

### Transitions between states: connecting one state, view, or element to another
- **Crossfade**: One element fades out as another fades in, in the same spot.
- **Continuity transition**: A change that keeps the user oriented by visually connecting before and after. For example, making the same rectangle bigger and smaller.
- **Morph**: One shape smoothly turns into another shape, e.g. Dynamic Island.
- **Shared element transition**: An element travels and transforms from one position into another, like a thumbnail expanding into a card.
- **Layout animation**: When an element's size or position changes, it animates to the new spot instead of snapping.
- **Accordion / Collapse**: A section smoothly expands and collapses its height to show or hide content.
- **Direction-aware transition**: Content slides one way going forward and the opposite way going back, so navigation has a sense of direction.

### Scroll: motion tied to scrolling or navigating between views
- **Scroll reveal**: Elements fade or slide into place as they enter the viewport.
- **Scroll-driven animation**: An animation whose progress is tied directly to scroll position.
- **Parallax**: Background and foreground move at different speeds while scrolling, creating depth.
- **Page transition**: An animation that plays when navigating from one page or route to another.
- **View transition**: The browser morphs between two states or pages, connecting shared elements.

### Feedback and interaction: responding to the user's actions
- **Hover effect**: Visual change when the cursor moves over an element.
- **Press / Tap feedback**: A subtle scale-down when an element is clicked, so it feels physical.
- **Hold to confirm**: A progress effect that fills up while the user holds a button.
- **Drag**: Moving an element by grabbing it, often with momentum when released.
- **Drag to reorder**: Dragging items in a list to rearrange them, while the others shift to make room.
- **Swipe to dismiss**: Dragging an element off-screen to close it, like a drawer or toast.
- **Rubber-banding**: Resistance and snap-back when you drag past a boundary (the iOS overscroll feel).
- **Shake / Wiggle**: A quick side-to-side jitter signaling an error or rejected input.
- **Ripple**: A circle expanding from the point of a tap, confirming the press.

### Easing: how speed changes over an animation
- **Easing**: The rate at which an animation speeds up or slows down.
- **Ease-out**: Starts fast, ends slow. The default for most UI and anything responding to the user.
- **Ease-in**: Starts slow, ends fast. Usually avoided; can feel sluggish.
- **Ease-in-out**: Slow, fast, slow. Good for elements already on screen moving from A to B.
- **Linear**: Constant speed. Avoid for UI; reserve for spinners or marquees.
- **Cubic-bezier**: A custom easing curve you define for precise control.
- **Asymmetric easing**: A curve that accelerates and decelerates at different rates. Feels more alive than a symmetric one.

### Spring animations: physics-based motion as an alternative to fixed-duration easing
- **Spring**: Motion driven by physics (tension, mass, damping) rather than a set duration.
- **Stiffness / Tension**: How strongly the spring pulls toward its target. Higher feels snappier.
- **Damping**: How quickly a spring settles. Lower damping means more bounce and oscillation.
- **Mass**: How heavy the animated element feels. More mass makes it slower and more sluggish.
- **Bounce**: A spring that overshoots and settles, adding playfulness.
- **Perceptual duration**: How long a spring feels finished, even though it keeps micro-settling underneath.
- **Momentum**: Motion that carries velocity, especially after a drag or interruption.
- **Velocity**: How fast and in which direction an element is moving. A spring carries it into the next animation when interrupted, so a flicked element keeps its speed.
- **Interruptible animation**: An animation that can be smoothly redirected mid-flight instead of finishing first.

### Looping and ambient motion: animations that run on their own
- **Marquee**: Text or content that scrolls continuously in a loop.
- **Loop**: An animation that repeats, a set number of times or infinitely.
- **Alternate (yoyo)**: A loop that plays forward then reverses each iteration, instead of jumping back to the start.
- **Orbit**: An element circling around another in a continuous path.
- **Pulse**: A gentle repeating scale or opacity change to draw attention.
- **Float**: A gentle, continuous up-and-down drift that makes a static element feel alive and weightless.
- **Idle animation**: Subtle motion that plays while an element is just sitting there, waiting to be interacted with.

### Polish and effects: the small touches that separate good from great
- **Blur**: A blur filter used to soften an element or mask tiny imperfections.
- **Clip-path**: Clipping an element to a shape, used for reveals, masks, and before/after sliders.
- **Mask**: Hiding or revealing parts of an element using a shape or gradient, like clip-path but with soft, fadeable edges.
- **Before / after slider**: A draggable divider that wipes between two overlaid images to compare them.
- **Line drawing**: An SVG path that draws itself in, like an invisible pen tracing it.
- **Text morph**: Text that animates character by character when it changes, drawing attention to the new value.
- **Skeleton / Shimmer**: A placeholder with a moving sheen shown while content loads.
- **Number ticker**: Digits rolling or counting up to a value.
- **Tabular numbers**: Fixed-width digits so numbers don't shift around as they change. Essential for tickers, timers, and counters.
- **Typewriter**: Text appearing one character at a time, as if being typed.

### Performance: what keeps motion smooth instead of stuttering
- **Frame rate (FPS)**: Frames drawn per second. 60fps is the baseline for smooth motion; 120fps on newer displays.
- **Jank**: Visible stutter when the browser drops frames because it can't keep up with the animation.
- **Dropped frame**: A frame the browser missed its deadline to draw, causing a tiny hitch in motion.
- **Compositing**: Letting the GPU move or fade an element on its own layer without redoing layout or paint.
- **will-change**: A CSS hint that an element is about to animate, so the browser can promote it to its own layer ahead of time.
- **Layout thrashing**: Animating properties like width, height, top, or left that force the browser to recalculate layout every frame, causing jank.

### Principles to know: concepts that guide when and how to animate
- **Purposeful animation**: Motion should serve a function (orient, give feedback, show relationships), not just decorate.
- **Anticipation**: A small wind-up in the opposite direction before a move, hinting at what's about to happen.
- **Follow-through**: Parts of an element keep moving and settle slightly after the main motion stops, adding weight.
- **Squash and stretch**: Deforming an element as it moves to convey weight, speed, and flexibility.
- **Perceived performance**: The right animation makes an interface feel faster, even when it isn't.
- **Frequency of use**: The more often a user sees an animation, the shorter and subtler it should be.
- **Spatial consistency**: Animating so an element keeps its identity and position across states, so users never lose track of where things went.
- **Hardware acceleration**: Animating transform and opacity lets the GPU keep motion smooth.
- **Reduced motion**: Respecting the user's prefers-reduced-motion setting by toning down or removing motion.
scripts/extract_frames.py
#!/usr/bin/env python3
"""Extract frames from a screen recording and build a contact sheet.

Run:
    python3 scripts/extract_frames.py <video> <outdir> [--fps N] [--cols C]
                                      [--start SECONDS] [--duration SECONDS]

On a multi-second recording, trim to just the transition with --start/--duration;
extracting the whole clip floods the contact sheet and dilutes tracking.

Produces:
    <outdir>/frame_0001.png ...      zero-padded, one per sampled frame
    <outdir>/contact_sheet.png       montage of every frame, left-to-right top-to-bottom

The contact sheet lets one vision read cover the whole timeline. Open it first,
then drill into individual frames only where the motion is interesting.

Only ffmpeg is required for this script. Tracking/fitting need extra packages.
"""

import argparse
import os
import shutil
import subprocess
import sys

# UI transitions of interest are usually 0.3-1.0s. 30 fps captures the
# sub-frame easing detail (over-stretch, settle, bounce) that lower rates blur
# together, while staying cheap to look at. Override with --fps for slow/long clips.
DEFAULT_FPS = 30

# Contact-sheet column count. ~8 keeps each frame large enough to read on a
# typical clip (a 0.5s clip at 30fps -> 15 frames -> 2 rows) without shrinking
# thumbnails to mush. Override with --cols for very long clips.
DEFAULT_COLS = 8


def require_ffmpeg():
    if shutil.which("ffmpeg") is None:
        sys.exit(
            "ffmpeg not found on PATH. Install it first:\n"
            "  macOS:  brew install ffmpeg\n"
            "  Debian: sudo apt-get install ffmpeg"
        )


def run(cmd):
    """Run a command, surfacing ffmpeg's own error text on failure."""
    proc = subprocess.run(cmd, capture_output=True, text=True)
    if proc.returncode != 0:
        sys.exit(f"command failed: {' '.join(cmd)}\n{proc.stderr.strip()}")
    return proc


def main():
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("video", help="path to the screen recording (mp4, mov, gif, ...)")
    p.add_argument("outdir", help="directory for extracted frames (created if missing)")
    p.add_argument("--fps", type=int, default=DEFAULT_FPS, help=f"frames per second to sample (default {DEFAULT_FPS})")
    p.add_argument("--cols", type=int, default=DEFAULT_COLS, help=f"contact-sheet columns (default {DEFAULT_COLS})")
    p.add_argument("--start", type=float, default=None, help="window start in seconds (trim long recordings)")
    p.add_argument("--duration", type=float, default=None, help="window length in seconds from --start")
    args = p.parse_args()

    require_ffmpeg()

    if not os.path.isfile(args.video):
        sys.exit(f"video not found: {args.video}")
    os.makedirs(args.outdir, exist_ok=True)  # solve, don't punt: create it

    # Trim as OUTPUT seeking (-ss/-t AFTER -i): frame-accurate, which matters for a
    # sub-second window. Input seeking (before -i) is faster but snaps to keyframes
    # and can miss the first frames of the transition.
    window = []
    if args.start is not None:
        window += ["-ss", str(args.start)]
    if args.duration is not None:
        window += ["-t", str(args.duration)]

    frame_glob = os.path.join(args.outdir, "frame_%04d.png")
    run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
         "-i", args.video, *window, "-vf", f"fps={args.fps}", frame_glob])

    frames = sorted(f for f in os.listdir(args.outdir) if f.startswith("frame_") and f.endswith(".png"))
    if not frames:
        sys.exit("ffmpeg produced no frames. Is the video readable and non-empty?")

    rows = (len(frames) + args.cols - 1) // args.cols
    sheet = os.path.join(args.outdir, "contact_sheet.png")
    run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
         "-pattern_type", "glob", "-i", os.path.join(args.outdir, "frame_*.png"),
         "-vf", f"tile={args.cols}x{rows}:padding=4:color=white", "-frames:v", "1", sheet])

    win = ""
    if window:
        end = "" if args.duration is None else f"-{(args.start or 0) + args.duration:g}s"
        win = f" (window {args.start or 0:g}s{end})"
    print(f"extracted {len(frames)} frames at {args.fps} fps{win} -> {args.outdir}")
    print(f"contact sheet: {sheet}  ({args.cols}x{rows})")
    print("Open the contact sheet first to read the whole timeline at once.")


if __name__ == "__main__":
    main()
scripts/fit_curves.py
#!/usr/bin/env python3
"""Fit easing curves to a per-frame metrics timeline.

Run:
    python3 scripts/fit_curves.py <metrics.json> [--fps N] [--property NAME]

For each animated property (position, scale, opacity, ...) this fits two models
to its normalized 0->1 progress over time:

    spring   damped harmonic oscillator -> { stiffness, damping, mass }
    bezier   cubic-bezier easing        -> { x1, y1, x2, y2 }

Both report a normalized RMS fit error. Lower is better; a spring that clearly
overshoots will beat a bezier (and vice versa). Read references/curve-fitting.md
to interpret the numbers and pick the model. A high error on BOTH usually means
the motion is multi-phase: split it and fit each phase separately.

Requires: numpy, scipy  (pip install numpy scipy)
"""

import argparse
import json
import sys

try:
    import numpy as np
    from scipy.optimize import curve_fit
except ImportError:
    sys.exit("missing deps. Install with:  pip install numpy scipy")

DEFAULT_FPS = 30  # must match the fps used in extract_frames.py

# Properties to attempt, mapped to the timeline field(s) that express them.
# Position uses centroid distance from the first tracked frame. Width and height
# are fit as SEPARATE axes: a "fluid" morph over-stretches one axis (usually
# vertical) ahead of the other, and the edges settle independently; fitting a
# single uniform scale would erase that signature effect.
PROPERTIES = {
    "translate": ("cx", "cy"),
    "scaleX": ("width",),
    "scaleY": ("height",),
    "opacity": ("opacity",),
    "blur": ("blur",),
    "radius": ("radius",),
}


def progress(series):
    """Normalize a raw measurement series to 0->1 progress along its own range.

    Returns None if the property barely changes (range below a hair of its scale),
    so we don't fit curves to noise.
    """
    a = np.asarray(series, float)
    lo, hi = a.min(), a.max()
    span = hi - lo
    ref = max(abs(hi), abs(lo), 1.0)
    if span < 0.01 * ref:  # <1% movement: property is effectively static
        return None
    return (a - a[0]) / (a[-1] - a[0]) if a[-1] != a[0] else (a - lo) / span


def spring_model(t, zeta, omega):
    """Unit-step response of a 2nd-order system, normalized to settle at 1.

    Underdamped (zeta<1) overshoots and rings; critically/over-damped eases in.
    We fit (zeta, omega) then convert to stiffness/damping/mass at the end.
    """
    t = np.asarray(t, float)
    if zeta < 1.0:
        wd = omega * np.sqrt(max(1 - zeta * zeta, 1e-9))
        phi = np.arctan2(zeta, np.sqrt(max(1 - zeta * zeta, 1e-9)))
        return 1 - np.exp(-zeta * omega * t) * np.cos(wd * t - phi) / np.cos(phi)
    return 1 - (1 + omega * t) * np.exp(-omega * t)  # critically-damped form


def bezier_progress(t, x1, y1, x2, y2):
    """Sample a cubic-bezier easing y for each x=t in [0,1] (CSS timing-function)."""
    t = np.clip(np.asarray(t, float), 0, 1)
    # Solve bezier-x(s)=t for parameter s by bisection, then return bezier-y(s).
    s = np.full_like(t, 0.5)
    lo, hi = np.zeros_like(t), np.ones_like(t)
    for _ in range(40):  # 40 bisections -> ~1e-12 precision, ample for easing
        bx = 3 * (1 - s) ** 2 * s * x1 + 3 * (1 - s) * s ** 2 * x2 + s ** 3
        lo = np.where(bx < t, s, lo)
        hi = np.where(bx >= t, s, hi)
        s = (lo + hi) / 2
    return 3 * (1 - s) ** 2 * s * y1 + 3 * (1 - s) * s ** 2 * y2 + s ** 3


def rms(a, b):
    return float(np.sqrt(np.mean((np.asarray(a) - np.asarray(b)) ** 2)))


def fit_property(prog, t):
    out = {}

    # Spring fit. Bounded so the optimizer stays in physically sane territory:
    # zeta in (0,2] spans bouncy->overdamped; omega in (0,50] covers fast UI.
    # p0 starts mid-range, zeta 0.6 (mildly bouncy) at omega 8 rad/s (~0.8s
    # settle), so the optimizer can converge toward either extreme.
    try:
        (zeta, omega), _ = curve_fit(spring_model, t, prog, p0=[0.6, 8.0],
                                     bounds=([0.01, 0.1], [2.0, 50.0]), maxfev=10000)
        pred = spring_model(t, zeta, omega)
        # Convert to the (stiffness, damping, mass) triple APIs expect, fixing mass=1.
        mass = 1.0
        stiffness = omega * omega * mass
        damping = 2 * zeta * omega * mass
        out["spring"] = {
            "stiffness": round(float(stiffness), 1),
            "damping": round(float(damping), 2),
            "mass": mass,
            "zeta": round(float(zeta), 3),
            "overshoot": bool(zeta < 1.0),
            "error": round(rms(pred, prog), 4),
        }
    except (RuntimeError, ValueError):
        out["spring"] = {"error": None, "note": "spring fit failed to converge"}

    # Bezier fit. Control-point x in [0,1] (monotonic time), y bounded a bit
    # past [0,1] (here ±0.5) so it can express overshoot like
    # cubic-bezier(.2,1.4,.3,1). p0 = [0.25, 0.1, 0.25, 1.0] ≈ the CSS `ease`
    # default, a neutral curve every UI easing is a small step away from.
    try:
        (x1, y1, x2, y2), _ = curve_fit(bezier_progress, t / t[-1], prog, p0=[0.25, 0.1, 0.25, 1.0],
                                        bounds=([0, -0.5, 0, 0.5], [1, 1.5, 1, 1.5]), maxfev=10000)
        pred = bezier_progress(t / t[-1], x1, y1, x2, y2)
        out["bezier"] = {
            "cubic_bezier": [round(float(x1), 3), round(float(y1), 3), round(float(x2), 3), round(float(y2), 3)],
            "css": f"cubic-bezier({round(float(x1),3)}, {round(float(y1),3)}, {round(float(x2),3)}, {round(float(y2),3)})",
            "error": round(rms(pred, prog), 4),
        }
    except (RuntimeError, ValueError):
        out["bezier"] = {"error": None, "note": "bezier fit failed to converge"}

    best = min((m for m in (out["spring"].get("error"), out["bezier"].get("error")) if m is not None), default=None)
    if best is not None:
        out["recommended"] = "spring" if out["spring"].get("error") == best else "bezier"
    return out


def main():
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("metrics", help="metrics.json from track_motion.py")
    p.add_argument("--fps", type=int, default=DEFAULT_FPS, help=f"fps used during extraction (default {DEFAULT_FPS})")
    p.add_argument("--property", default=None, help="fit only this property (translate|scaleX|scaleY|opacity|blur|radius)")
    args = p.parse_args()

    with open(args.metrics) as f:
        data = json.load(f)
    tl = [r for r in data["timeline"] if r.get("present") is not False]
    if len(tl) < 4:
        sys.exit("need at least 4 tracked frames to fit a curve")

    frames = np.array([r["frame"] for r in tl], float)
    t = (frames - frames[0]) / args.fps  # seconds from first tracked frame
    duration_ms = round(float(t[-1] * 1000))

    if args.property and args.property not in PROPERTIES:
        sys.exit(f"unknown property '{args.property}'. Choose from: {', '.join(PROPERTIES)}")
    wanted = {args.property: PROPERTIES[args.property]} if args.property else PROPERTIES

    result = {"duration_ms": duration_ms, "tracked_frames": len(tl), "properties": {}}
    for name, fields in wanted.items():
        if name == "translate":
            cx = np.array([r["cx"] for r in tl]); cy = np.array([r["cy"] for r in tl])
            raw = np.hypot(cx - cx[0], cy - cy[0])  # distance travelled from start
        else:
            raw = np.array([r[fields[0]] for r in tl], float)
        prog = progress(raw)
        if prog is None:
            continue  # property didn't move enough to be worth fitting
        result["properties"][name] = fit_property(prog, t)

    if not result["properties"]:
        sys.exit("no property changed enough to fit. Check that you tracked the right element.")

    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()
scripts/track_motion.py
#!/usr/bin/env python3
"""Measure a moving element across extracted frames, frame by frame.

Run:
    python3 scripts/track_motion.py <framedir> [--out metrics.json]
                                    [--bbox X,Y,W,H] [--invert] [--threshold T]

--bbox restricts frame-diff detection to that region (use when several elements
move and you want just one; run once per element). The element is still tracked
inside the region, so position/scale/opacity stay meaningful.

Outputs a JSON timeline of per-frame measurements for the tracked element:
    x, y            top-left of its bounding box (pixels)
    cx, cy          centroid (pixels)
    width, height   bounding-box size (pixels) -> scale proxy
    opacity         mean foreground alpha proxy in [0,1]
    blur            normalized inverse sharpness in [0,1] (1 = most blurred)
    radius          corner-roundness proxy in [0,1] (1 = most rounded)

These are PROXIES from pixels, not ground truth. Read references/measurement-guide.md
for what each one means and when to trust it. Feed the JSON to fit_curves.py.

Requires: opencv-python, numpy  (pip install opencv-python numpy)
"""

import argparse
import json
import os
import sys

try:
    import cv2
    import numpy as np
except ImportError:
    sys.exit("missing deps. Install with:  pip install opencv-python numpy")

# Frames whose foreground mask covers less than this fraction of the searched
# area (full canvas, or the --bbox region when given) are treated as "element
# not present yet" (empty) rather than noise-filled. 0.2% of pixels is below any
# real UI element but above stray anti-aliasing specks.
MIN_AREA_FRAC = 0.002

# Laplacian variance saturates well before pixel max; 1000 is a generous ceiling
# for "perfectly sharp" UI text/edges, used only to normalize blur into [0,1].
SHARPNESS_CEILING = 1000.0

# Default per-pixel intensity-diff cutoff (0-255) for foreground detection.
# Above compression/AA noise, below real element edges. See foreground_mask().
DEFAULT_THRESHOLD = 25


def load_frames(framedir):
    names = sorted(f for f in os.listdir(framedir) if f.startswith("frame_") and f.endswith(".png"))
    if not names:
        sys.exit(f"no frame_*.png in {framedir}; run extract_frames.py first")
    frames = []
    for n in names:
        img = cv2.imread(os.path.join(framedir, n))
        if img is None:
            sys.exit(f"could not read {n}")
        frames.append(img)
    return names, frames


def foreground_mask(gray, ref, threshold, invert):
    """Foreground = pixels that differ from the first frame (the resting background).

    Works for an element that enters/moves over a static backdrop, which is the
    common screen-recording case. Pass --bbox to restrict detection to a region.
    """
    diff = cv2.absdiff(gray, ref)
    # Fixed intensity-difference threshold. ~25/255 sits above JPEG/video
    # compression and anti-aliasing noise (typically <15) yet well below the edge
    # contrast of any real UI element, so it isolates the moving element across a
    # static backdrop. A statistical threshold (mean+k*std) misfires here: when the
    # element covers a large, high-contrast area it pushes the cutoff past 255 and
    # detects nothing. Override with --threshold for low-contrast motion.
    t = threshold if threshold is not None else DEFAULT_THRESHOLD
    _, mask = cv2.threshold(diff, t, 255, cv2.THRESH_BINARY)
    if invert:
        mask = cv2.bitwise_not(mask)
    return mask


def measure(img, mask, min_area):
    ys, xs = np.where(mask > 0)
    area = xs.size
    if area < min_area:
        return None  # element effectively absent in this frame

    x0, x1 = int(xs.min()), int(xs.max())
    y0, y1 = int(ys.min()), int(ys.max())
    bw, bh = x1 - x0 + 1, y1 - y0 + 1

    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    region = gray[y0:y1 + 1, x0:x1 + 1]

    # opacity proxy: how much of the bounding box the foreground fills. A fading-in
    # element fills less; a solid one fills its box. Normalized by box area.
    opacity = float(area) / float(bw * bh)

    # blur proxy: Laplacian variance is high for sharp edges, low for blurred.
    # Invert + normalize so 1.0 = most blurred, 0.0 = sharp.
    sharp = float(cv2.Laplacian(region, cv2.CV_64F).var())
    blur = 1.0 - min(sharp / SHARPNESS_CEILING, 1.0)

    # radius proxy: fraction of the four bounding-box corners NOT covered by the
    # mask. Rounded corners leave the box corners empty; sharp corners fill them.
    sub = mask[y0:y1 + 1, x0:x1 + 1]
    corner = max(2, min(bw, bh) // 8)  # sample an 1/8-edge corner patch, min 2px
    corners = [sub[:corner, :corner], sub[:corner, -corner:], sub[-corner:, :corner], sub[-corner:, -corner:]]
    filled = sum(c.mean() / 255.0 for c in corners) / 4.0
    radius = float(1.0 - filled)

    return {
        "x": x0, "y": y0, "width": bw, "height": bh,
        "cx": (x0 + x1) / 2.0, "cy": (y0 + y1) / 2.0,
        "opacity": round(opacity, 4), "blur": round(blur, 4), "radius": round(radius, 4),
    }


def main():
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("framedir", help="directory of frame_*.png from extract_frames.py")
    p.add_argument("--out", default=None, help="output JSON path (default: <framedir>/metrics.json)")
    p.add_argument("--bbox", default=None, help="restrict detection to region X,Y,W,H (one run per element)")
    p.add_argument("--threshold", type=int, default=None, help="fixed foreground diff threshold (0-255)")
    p.add_argument("--invert", action="store_true", help="treat the matched region as background, not foreground")
    args = p.parse_args()

    names, frames = load_frames(args.framedir)
    ref = cv2.cvtColor(frames[0], cv2.COLOR_BGR2GRAY)

    clip = None
    if args.bbox:
        try:
            X, Y, W, H = (int(v) for v in args.bbox.split(","))
        except ValueError:
            sys.exit("--bbox must be X,Y,W,H integers, e.g. --bbox 40,120,300,400")
        fh, fw = ref.shape
        if X < 0 or Y < 0 or W <= 0 or H <= 0 or X + W > fw or Y + H > fh:
            sys.exit(f"--bbox {args.bbox} falls outside the {fw}x{fh} frame")
        clip = np.zeros(ref.shape, np.uint8)
        clip[Y:Y + H, X:X + W] = 255

    # Presence cutoff scales with the searched area: the bbox when given (a small
    # element in a small region should still register), else the full canvas.
    min_area = MIN_AREA_FRAC * (W * H if clip is not None else ref.size)

    timeline = []
    for i, (name, img) in enumerate(zip(names, frames)):
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        mask = foreground_mask(gray, ref, args.threshold, args.invert)
        if clip is not None:
            # Keep only motion inside the bbox so neighboring elements don't
            # pollute the measurement; the element is still tracked within it.
            mask = cv2.bitwise_and(mask, clip)
        m = measure(img, mask, min_area)
        timeline.append({"frame": i, "file": name, **(m or {"present": False})})

    present = [t for t in timeline if t.get("present") is not False]
    if not present:
        sys.exit("no element detected in any frame. Try --bbox to name the region, "
                 "or --threshold to loosen detection, or --invert.")

    out = args.out or os.path.join(args.framedir, "metrics.json")
    with open(out, "w") as f:
        json.dump({"frame_count": len(timeline), "tracked_frames": len(present), "timeline": timeline}, f, indent=2)
    print(f"measured {len(present)}/{len(timeline)} frames -> {out}")
    if len(present) < len(timeline):
        print(f"({len(timeline) - len(present)} frames had no element; likely before entry or after exit)")


if __name__ == "__main__":
    main()