Claude Skill

ui-animation

Builds, reviews, and measures UI motion, including springs, gestures, scroll effects, curve fitting from recordings, and sparse interface sound. Use when asked to "add animation", "match this easing", "reverse engineer this motion", "add a click sound", or find animation opportun

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download mblode-agent-skills-skills_ui-animation-24f4fd8.zip · 82 KB
Part of mblode/agent-skills — 22 skills

Install

skills CLI npx skills add https://github.com/mblode/agent-skills/tree/main/skills/ui-animation
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install mblode-agent-skills@llmmart
Git git clone https://github.com/mblode/agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole mblode/agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

UI Animation

  • IS: designing, implementing, reviewing, debugging UI motion (springs, gestures, drag, easing, CSS transitions, keyframes, Motion), sweeping an interface for the moments that would genuinely benefit from motion, measuring motion from a recording (extract frames, track, fit curves) to emit code plus a handoff spec, naming a described motion effect (reverse-lookup vocabulary), and gating sparse interface sound.
  • IS NOT: choosing overall visual direction, palettes, or typography (use ui-design Direction mode), auditing a whole page's UI quality (use ui-design Audit mode), or named text-effect specs (use the external animate-text skill where installed).

Routing boundary

product-design owns action semantics, scope, reversibility, and contested state choices. ui-design builds and styles those states. ui-animation owns timing, gestures, and measured motion. A routine missing loading or error state stays with the UI build; a gesture replacing a control needs a product decision and an accessible alternative before its physics.

Reference files

File Read when
references/discovery-workflow.md Finding worthwhile opportunities for motion in an existing interface
references/decision-framework.md Default: deciding whether/why to animate, picking easing character; also the seam list for a Discovery sweep
references/spring-animations.md Spring physics, Motion useSpring, configuring spring params, Apple damping/response values, asymmetric open/close character, interruption mechanics
references/component-patterns.md Buttons, popovers, tooltips, drawers, modals, toasts with animation
references/clip-path-techniques.md clip-path for reveals, tabs, hold-to-delete, comparison sliders
references/gesture-drag.md Drag, swipe-to-dismiss, momentum, pointer capture, velocity handoff, momentum projection, rotary/knob drag, detents, carousel touch-action
references/scroll-animations.md Scroll-triggered reveals, scrubbed/scroll-driven animation (animation-timeline, useScroll), parallax, sticky scrollytelling, and when a scroll animation shouldn't exist
references/performance-deep-dive.md Jank, CSS vs JS, WAAPI, CSS variables trap, Framer Motion caveats
references/debugging-symptoms.md An animation feels off and the cause isn't named: symptom-indexed tables for sluggish, robotic, cheap, jumpy, and misfiring motion
references/svg-animation.md Animating vector art: line drawing (stroke-dashoffset), SVG transform-origin traps, path morphing, shakes, ambient life
references/review-format.md Reviewing animation code: ten standards (each with flag-on-sight triggers), Before/After/Why table, Block/Approve verdict
references/contextual-animations.md Contextual icon swaps, word-level stagger entrances, peripheral de-emphasis, fixed-offset exits
references/transition-recipes.md Installing a CSS transition: container morph, card resize, badge, dropdown, modal, panel, page slide, icon swap, number pop-in, odometer roll, text swap, success, avatar hover, error shake
references/measurement-guide.md Reverse-engineer: what to measure, eye vs script, reading metrics.json, choosing an ROI
references/curve-fitting.md Reverse-engineer: reading fit_curves.py output, spring vs bezier, judging fit error, asymmetric open/close
references/code-output.md Reverse-engineer: emitting code for CSS, Motion/Framer Motion, SwiftUI, React Native, UIKit
references/choreography.md Reverse-engineer: multi-element/multi-phase motion: staggers, blur-before-move, per-edge settling
references/live-tuning.md Dialling a curve in live when there is no reference to fit against: the DevTools bezier editor, retiming in the Animations panel, when a control-panel library earns a dependency
references/vocabulary.md Naming a motion effect the user describes vaguely ("what's it called when...")
references/interface-sfx.md Click sounds, interface audio, UI SFX, haptic-plus-sound, or "why is the web afraid of sound"

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.
  • Keep keyboard focus and repeated navigation immediate. A state transition may animate if focus and task completion do not wait for it.
  • 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.
  • Tappable controls press on :active at 0ms and set touch-action: manipulation.
  • 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. Better still where the shapes allow: let the trigger become the surface (see the container-morph recipe).
  • Confirm in place, not in a corner. An action's result belongs on the control that caused it: the button becomes "Copied", holds, and reverts. A toast in the far corner makes the user's eye leave the thing they just touched to find out whether it worked. Reserve corner toasts for results with no on-screen origin (a background job finishing, an incoming message).
  • 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 tween, see the card-resize and container-morph recipes.)
  • 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. Line drawing, path morphing, and the Motion SVG origin override live in references/svg-animation.md.
  • 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. Force a reflow (void document.body.offsetHeight) after the flip and remove the override on the next frame, or use next-themes disableTransitionOnChange.

Easing defaults

Element Duration Easing
Button press feedback 100-160ms cubic-bezier(0.22, 1, 0.36, 1)
Tooltips, small popovers 125-200ms ease-out or enter curve
Dropdowns, selects 150-250ms cubic-bezier(0.22, 1, 0.36, 1)
Modals, drawers 200-350ms cubic-bezier(0.22, 1, 0.36, 1)
Move/slide on screen 200-300ms cubic-bezier(0.25, 1, 0.5, 1)
Page transitions 250-400ms enter or move curve
Hover (colour/opacity) 200ms ease
Hover (transform/scale) 100-150ms enter curve
Illustrative/marketing Up to 1000ms Spring 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) (extremely steep start; the reason its 500ms doesn't read as slow)
  • Expo out: cubic-bezier(0.19, 1, 0.22, 1) for dramatic reveals, card hovers, text reveals
  • Press: cubic-bezier(0.25, 0.46, 0.45, 0.94) for button press feedback
  • On-screen move: cubic-bezier(0.645, 0.045, 0.355, 1) for back-and-forth movement that stays on screen

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 pattern Recipe
Trigger + floating dot/count Notification badge
Trigger grows into the surface it opens Container morph
Trigger + anchored surface Menu dropdown
Centred surface on top of page Modal dialog
Panel sliding into existing container Panel reveal
List ↔ detail or wizard steps Page side-by-side slides
Element dimension changes Card resize
Text updating in place Text state swap
Two icons in same slot Icon swap
Number arriving on its own Number pop-in
Number the user is driving Odometer digit roll
Confirmation / success moment Success celebration
Hovering item in horizontal stack Avatar group hover
Form validation error Error 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.9-0.96) not scale(0) (small popovers at the low end, full dialogs at the high end: a large surface already travels far in absolute pixels), 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

  • Gate hover (motion and paint) behind @media (hover: hover) and (pointer: fine), or touch devices replay hover on tap. Inspect the generated CSS before adding a gate; Tailwind v4 already wraps hover: in @media (hover: hover).
  • During direct manipulation, keep the element locked to the pointer with no easing; add easing only after release.
  • Optional interface SFX: sparse, gesture-unlocked, additive confirmation only. See references/interface-sfx.md.

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 when one owner must combine multiple transform functions, interop with non-Motion code, or survive a busy main thread: the shorthands run on requestAnimationFrame and drop frames when motion coincides with navigation, data loading, or hydration; CSS/WAAPI stay smooth there.
  • Motion that janks only sometimes (on open, during navigation, while data lands) is usually a long task sharing the tick, not a costly animation. Don't start an animation and expensive work in the same tick: start the motion, let a frame land, then do the work, or defer it to transitionend.
  • See references/performance-deep-dive.md for WAAPI, compositing layers, long tasks during animation, 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.
  • Scroll-revealing product UI, above-the-fold content, or every section of a page: scroll reveals belong to a few chosen moments on marketing surfaces, run once, and never re-animate on scroll-up (see references/scroll-animations.md).
  • Easing or duration on scrubbed (scroll-driven) motion: scroll position is the clock, so any curve or duration makes it lag the scrollbar. linear and no duration is correct there, and only there.
  • Installing framer-motion for new work: the package is now motion and React imports come from motion/react. The old package still resolves, so a mixed codebase compiles while shipping two copies of the library.

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. If the value is contested or the component is hard to reach, dial it live in the DevTools bezier editor rather than guessing, then bake the result into source (references/live-tuning.md).
  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.
  • 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.
  • Honor prefers-reduced-motion: replace spatial travel with immediate state changes or restrained fades. Pause looping decorations with animation-play-state: paused (do not yank them with display: none). Keep explicit user-triggered feedback. Exercise the same task in that mode.

Discovery workflow

For "where should this animate", load references/discovery-workflow.md and references/decision-framework.md. Report opportunities supported by purpose and usage frequency. Implement a suggestion only when implementation is in scope.

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.

Resolve every scripts/ command below relative to the installed skill directory, not the application working directory.

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.

Maintenance only: when changing Discovery routing or the gate, run the scenarios in evaluations/ as a regression rubric. They never load during a user task.

Sources

Interface SFX gating taken from Craft (gustavo-fior) and Raphael Salaja's web-sound writing. Novelty 90/10 split, one-shot intro gating, and animation-play-state on loops taken from Rauno Freiberg. Rejected vendoring emilkowalski/skills and gustavo-fior/craft: trigger collision with this skill. Clip-path and proportional scale already lived here.

Related skills

  • product-design: which states exist, what an action affects, and whether it is reversible. Route here first when a gesture replaces a control, since swipe-to-delete and hold-to-confirm change what the user can do before they change how it moves.
  • ui-design Direction mode: visual direction, palettes, typography; settle the visual system before tuning motion.
  • ui-design Audit mode: page/feature-level UI quality audit. Motion craft and fixes belong here.
  • Optional external animate-text skill where installed: curated named text effects (typewriter, line reveal, stagger builds) with exact JSON specs.

Maintenance only: evals/evals.json contains regression scenarios for changes to this skill; it does not load during a user task.

Files (agent-skills)
  • evals
    • evals.json 2.2 KB
      {
        "skill_name": "ui-animation",
        "evals": [
          {
            "id": 1,
            "prompt": "Review a Tailwind v4 modal animation. Generated hover CSS already includes @media (hover: hover). Keyboard focus moves immediately; a 120ms opacity transition continues afterward. Reduced motion uses an immediate state change.",
            "expected_output": "Avoid false positives for already-gated hover and nonblocking keyboard feedback.",
            "files": [],
            "assertions": [
              "Inspects generated hover CSS",
              "Does not ban the transition solely because a keyboard opened it",
              "Checks reduced-motion task completion"
            ]
          },
          {
            "id": 2,
            "prompt": "Match a recording extracted at 60fps using the bundled fitting scripts. The fitting default is 30fps.",
            "expected_output": "Resolve installed script paths and carry the actual frame rate through fitting.",
            "files": [],
            "assertions": [
              "Passes 60fps to fitting",
              "Does not run scripts relative to the application by accident",
              "Reports fitted error rather than claiming an exact visual match"
            ]
          },
          {
            "id": 3,
            "prompt": "Add a click sound to every button on the dashboard, including list-row hovers.",
            "expected_output": "Refuse high-frequency SFX; if any sound ships, it is rare, gesture-unlocked, and additive to visual feedback.",
            "files": [],
            "assertions": [
              "Loads interface-sfx.md",
              "Keeps typing, hover, and list navigation silent",
              "Does not create AudioContext on page load"
            ]
          }
        ],
        "routing": {
          "should_trigger": [
            "Review a Tailwind v4 modal animation. Generated hover CSS already includes @media (hover: hover). Keyboard focus moves immediately; a 120ms opacity transition continues afterward. Reduced motion uses an immediate state change.",
            "Match a recording extracted at 60fps using the bundled fitting scripts. The fitting default is 30fps.",
            "Add a click sound to every button on the dashboard, including list-row hovers."
          ],
          "near_miss": [
            {
              "prompt": "Should swipe-to-delete be undoable?",
              "expected": "product-design"
            }
          ]
        }
      }
      
  • evaluations
    • fixtures
      • settings-panel.tsx 1.3 KB · in bundle
    • discovery-mode.json 1.7 KB
      [
        {
          "skills": [
            "ui-animation"
          ],
          "query": "Where should this animate? Nothing moves right now and it feels dead.",
          "files": [
            "fixtures/settings-panel.tsx"
          ],
          "expected_behavior": [
            "Selects the Discovery workflow, not the main build workflow: reports opportunities with file:line evidence instead of editing the fixture",
            "Flags the Advanced toggle as a feedback gap: onClick with no :active or transition",
            "Flags the {expanded && ...} conditional as teleporting state, and proposes an opacity plus transform entrance rather than an animated height",
            "Flags the saved confirmation as a rare high-emotion moment where the delight budget applies",
            "REJECTS CommandMenu explicitly: keyboard-initiated and opened 100+ times a day, so it never animates",
            "REJECTS the requests table: functional data the user is reading, where motion hinders",
            "Includes the rejected-candidates section, each naming the gate question that killed it",
            "Names a purpose (feedback, orientation, continuity, delight) for every surviving suggestion, and gives exact property, duration, and curve values drawn from the easing defaults table",
            "Caps the list at seven suggestions and does not implement any of them"
          ]
        },
        {
          "skills": [
            "ui-animation"
          ],
          "query": "The Advanced panel should slide open instead of popping.",
          "files": [
            "fixtures/settings-panel.tsx"
          ],
          "expected_behavior": [
            "Selects the main workflow, not Discovery: the user named the interaction, so there is nothing to sweep for",
            "Implements the transition rather than returning a report with a rejected-candidates section",
            "Animates transform and opacity, never height"
          ]
        }
      ]
      
  • references
    • choreography.md 2.5 KB
      # 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.
      
    • clip-path-techniques.md 2.9 KB
      # 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.
      
    • code-output.md 4.2 KB
      # 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.
      
    • component-patterns.md 11.5 KB
      # 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)
      - [Layout morphs and auto height (Motion)](#layout-morphs-and-auto-height-motion)
      - [3D transforms](#3d-transforms)
      
      ## Buttons
      
      Add `transform: scale(0.97)` on `:active` for instant press feedback. Press is 0ms; release may ease. `touch-action: manipulation` on the control drops the double-tap-zoom delay. Do not put it on `html`, a map, or a pinch-zoom lightbox.
      
      ```css
      .button {
        touch-action: manipulation;
        transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1);
      }
      .button:active {
        transform: scale(0.97);
        transition-duration: 0s;
      }
      ```
      
      `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
      /* Base UI. Radix exposes the same thing as --radix-popover-content-transform-origin */
      .popover {
        transform-origin: var(--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.92)`, never `scale(0)`: nothing appears from nothing.
      
      ```css
      .menu {
        transform: scale(0.92);
        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);
        }
      }
      ```
      
      `@starting-style` has been Baseline since August 2024, so the `data-mounted` attribute pattern is a fallback for browsers older than that, not the default. Ship the CSS above and add the attribute path only when the support matrix actually includes those browsers.
      
      ## 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. Tailwind `hover:` is not gated unless the project set `hoverOnlyWhenSupported` or a custom variant.
      
      ```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 150ms ease;
      }
      ```
      
      For scale-based hover, use `scale(1.01)` to `scale(1.02)`; `scale(1.05)` is visibly inflated. Transform hovers run 100-150ms, faster than the 200ms colour/opacity hover above: the user's eye is already on the element, so movement past 150ms reads as lag.
      
      ```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>
      ```
      
      ## Layout morphs and auto height (Motion)
      
      The `layout` and `layoutId` props cover what CSS can't animate, and each carries a gotcha that presents as a visual bug:
      
      - **`layout`** animates any layout change, including CSS-unanimatable properties like `flex-direction`. Change the element's *actual styles* (className or inline), not the `animate` prop; Motion measures before and after and interpolates. Add `layout` to neighbouring elements too, or they jump while the animating one glides.
      - **`layoutId`** morphs one element into another across mount/unmount: tab indicators, card-to-detail expansions, a button becoming a popover. You can't steer *how* a shared-layout morph moves; to add motion on top, animate the **parent** and let the children follow.
      - **Border radius distorts during layout animation** because the morph is transform-based scaling. Motion corrects the radius only when it's an inline pixel value: always `style={{ borderRadius: 12 }}`, never a className or `rem` radius, on anything with `layout`/`layoutId`.
      - **No `key`, no exit.** An `AnimatePresence` child without a `key` never unmounts, so the exit animation silently never fires (and `AnimatePresence` must wrap the conditional, not sit inside it). When an exit does nothing, check the key first.
      - **Exiting elements have stale props.** An `AnimatePresence` child that is animating out has already left the tree, so it can't see new state. Pass `custom` to both `AnimatePresence` and the `motion` element (as in the step-form pattern above), or direction-aware exits always leave the same way.
      
      **Auto height:** Motion can't animate `auto` to `auto`. Measure the content and animate to the pixel value:
      
      ```jsx
      import useMeasure from "react-use-measure";
      
      const [ref, bounds] = useMeasure();
      
      <motion.div animate={{ height: bounds.height ? bounds.height : null }}>
        <div ref={ref} className="inner">{content}</div>  {/* padding lives here */}
      </motion.div>
      ```
      
      The `ref` and the animated height must be on *different* elements; on the same one, the element freezes at its animated height and stops reacting to content changes. Put the padding on the inner element so the measurement includes it, and fall back to `null` (meaning `auto`) while `bounds.height` is `0` on first render to avoid a layout shift. `useMeasure` wraps `ResizeObserver`; hand-rolling it is a few lines if the dependency isn't wanted.
      
      When the same surface swaps content at different sizes, make the crossfade duration proportional to how much the height changed, so small changes don't over-animate:
      
      ```js
      const MIN = 0.15, MAX = 0.27;
      const delta = Math.abs(bounds.height - previousHeightRef.current);
      const duration = Math.min(Math.max(delta / 500, MIN), MAX);
      ```
      
      ## 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.
      
    • contextual-animations.md 5.7 KB
      # 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)
      - [Peripheral de-emphasis](#peripheral-de-emphasis)
      - [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.
      
      ---
      
      ## Peripheral de-emphasis
      
      To focus attention on one item in a set, animate the *siblings*, not the item. Blurring and fading the neighbours pushes them behind the focal plane, which reads as depth. A scrim over the whole page reads as a mode change, which is a much heavier claim than "this one is active".
      
      Use it for hover previews in a dense grid of chips or thumbnails, and for a picker whose options stay visible behind it. Do not use it as a substitute for a modal backdrop: a dialog that traps focus needs the scrim, because the dim is communicating that the rest of the page is inert, not merely secondary.
      
      ```css
      .chip {
        transition: opacity 200ms ease, filter 200ms ease, scale 150ms cubic-bezier(0.22, 1, 0.36, 1);
      }
      
      /* Blur the siblings of whatever is hovered, not the hovered chip. */
      @media (hover: hover) and (pointer: fine) {
        .chip-grid:has(.chip:hover) .chip:not(:hover) {
          opacity: 0.5;
          filter: blur(2px);
        }
        .chip:hover { scale: 1.04; }
      }
      ```
      
      Keep the blur at 2-3px. Past about 4px the neighbours stop reading as content and the grid looks broken rather than defocused. Fade to roughly 0.5 opacity, never to invisible: the point is that the set is still there.
      
      ---
      
      ## 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.
      
    • curve-fitting.md 4.6 KB
      # 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). The per-target templates come from
        the Emit step of SKILL.md's reverse-engineer workflow.
      
    • debugging-symptoms.md 7.4 KB
      # Debugging Symptoms
      
      Turn "this feels off" into a named cause, then make the smallest fix that addresses it. Never tweak values blindly: randomly nudging durations produces a different animation, not a better one, and destroys the ability to tell what actually helped.
      
      ## Contents
      - [The loop](#the-loop)
      - ["It feels slow / sluggish"](#it-feels-slow--sluggish)
      - ["It feels robotic / lifeless / flat"](#it-feels-robotic--lifeless--flat)
      - ["It feels cheap, but I can't say why"](#it-feels-cheap-but-i-cant-say-why)
      - ["It's janky / drops frames"](#its-janky--drops-frames)
      - ["It jumps / snaps / shifts"](#it-jumps--snaps--shifts)
      - ["It fires when it shouldn't / flickers"](#it-fires-when-it-shouldnt--flickers)
      - [When no row matches](#when-no-row-matches)
      
      ## The loop
      
      1. **Reproduce it on the environment where it feels wrong.** A gesture that's fine on a laptop can stutter on a phone; an opacity crossfade that's fine at 120Hz looks rough at 60Hz.
      2. **Slow it down.** Record and scrub frame by frame, or set the DevTools Animations panel to 10-25% playback. This is the single highest-leverage step: the flaw invisible at full speed (a late fade, a wrong origin, two states reading as separate objects) is obvious at quarter speed.
      3. **Classify the symptom** with the tables below; causes are ordered by likelihood.
      4. **Change one variable, re-record, compare.** Easing first, then duration: duration depends on the easing (a steep curve affords a longer duration), so tuning duration before the curve is settled is wasted work.
      5. **Verify at full speed, then with fresh eyes.** An animation approved only in slow motion hasn't been approved.
      
      ## "It feels slow / sluggish"
      
      | Check, in order | Fix |
      | --- | --- |
      | `ease-in` on the animation | Swap to a strong ease-out; `ease-in` starts slow, delaying the exact moment the user is watching. The same duration instantly feels faster. |
      | Built-in named easing (`ease-out`, `ease-in-out`) | Replace with a custom curve; built-ins accelerate too weakly, so motion feels flat and slow at any duration. |
      | Duration over ~300ms on product UI | Cut it. A 180ms dropdown feels more responsive than a 400ms one. Only a very steep curve earns a long duration. |
      | Animation on a high-frequency action (keyboard nav, shortcut toggle, constant hover) | Delete the animation. At 100+ uses a day any duration reads as lag; the fix is removal, not tuning. |
      | A `delay` in the chain | Remove or shrink it; delays on interactive responses read as the UI hesitating. |
      
      ## "It feels robotic / lifeless / flat"
      
      | Check, in order | Fix |
      | --- | --- |
      | `linear` easing on non-constant motion | Nothing physical moves at constant speed. Ease-out for enter/exit, ease-in-out for on-screen movement. `linear` only for marquees, spinners, time-visualizing holds, and scrubbed scroll motion. |
      | Curve too weak | Steepen it; when an animation feels flat, the curve is usually the problem, not the duration. |
      | A duration-based ease on something that should feel alive (drag release, morphing pill) | Use a spring; fixed durations can't carry velocity or an organic settle. A weird-feeling spring is usually fixed by raising damping. |
      | Uniform stagger (identical delay and distance per item) | Vary delay and distance by importance; the metronome effect is what feels mechanical. |
      
      ## "It feels cheap, but I can't say why"
      
      | Check, in order | Fix |
      | --- | --- |
      | Entrance from `scale(0)` or a bare fade | Start from `scale(0.9-0.96)` plus opacity; nothing real appears from nothing, and a near-full start reads as "it was almost already there". |
      | Wrong `transform-origin` | Popovers, dropdowns, and tooltips scale from their trigger, not center (use the library's origin variable: `--transform-origin` in Base UI, `--radix-popover-content-transform-origin` in Radix). Slowed playback makes a wrong origin unmistakable. |
      | Crossfade shows two distinct overlapping states | Add `filter: blur(2px)` during the transition; blur bridges the gap so the eye reads one transforming object instead of two swapped ones. |
      | Sub-animations on different clocks | Unify the timing family so the component reads as one entity; one slow sub-animation breaks the whole thing. |
      | Enter and exit mismatched | Exit in the direction of entry, roughly 20% faster and simpler than the entrance; the user already decided, get out of the way. |
      | Motion mismatched to personality | A playful app can bounce; a dashboard stays crisp. Feel can overrule the blueprint, but deliberately. |
      
      ## "It's janky / drops frames"
      
      Work the diagnosis checklist in `performance-deep-dive.md`; the short order is: non-`transform`/`opacity` properties first, then motion coinciding with a busy main thread (move to CSS/WAAPI, or stop co-scheduling the work), then per-frame React state updates, then an inherited CSS variable driving transforms, then animated `blur()` over 20px. Only after those, `will-change: transform`.
      
      If it janks only sometimes (on open, on the first run, during navigation, while data lands), the animation is fine and a long task is sharing the tick. Record a performance trace over the interaction and look for a task over 50ms; fix the scheduling, not the motion (see Long tasks during animation in `performance-deep-dive.md`).
      
      ## "It jumps / snaps / shifts"
      
      | Check, in order | Fix |
      | --- | --- |
      | Element jumps when retriggered quickly (new toast, rapid toggle) | `@keyframes` restart from zero; they aren't interruptible. Use CSS transitions or springs, which retarget from the current state with velocity. |
      | Exit animation never plays | The `AnimatePresence` child is missing a `key` (or `AnimatePresence` sits inside the conditional instead of around it). No key, no exit; check this first. |
      | Height snaps instead of animating | `height: auto` isn't animatable; measure it and animate the pixel value (see the auto-height pattern in `component-patterns.md`). |
      | 1px shift at animation start or end | `will-change: transform`; the browser is handing the element between CPU and GPU, which render slightly differently. |
      | Content flashes to its final state before animating | The initial state arrives after first paint. Set it in CSS (or `@starting-style`) so the element is born hidden. |
      
      ## "It fires when it shouldn't / flickers"
      
      | Check, in order | Fix |
      | --- | --- |
      | Hover element oscillates between states | The hover animation moves the element out from under the cursor, ending the hover, dropping it back in. Move the transform to an inner child; the parent stays put under the cursor. |
      | Hover states firing on phones | Touch taps trigger phantom hovers. Gate with `@media (hover: hover) and (pointer: fine)`. |
      | Every tooltip in a row animates as the cursor sweeps | Once one tooltip is open, siblings open with no delay and no animation (Base UI exposes `data-instant`; set `transition-duration: 0ms` on it). |
      | Animation replays every time it scrolls into view or on back-navigation | Intro and reveal animations run once. Unobserve after firing or persist a has-played flag. |
      
      ## When no row matches
      
      The animation may be correct and wrong anyway: built to spec but the spec is off. Re-derive the basics in order: should this animate at all (frequency)? Right easing family for the motion type? Duration matched to that easing and the element's size? If a reference exists (an app whose version feels right), record the reference and scrub both side by side; matching reality beats theorizing. When a crossfade resists all tuning, a 2px blur is the sanctioned last resort.
      
    • decision-framework.md 5.8 KB
      # 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)
      - [Finding opportunities: where motion is missing](#finding-opportunities-where-motion-is-missing)
      
      Answer these four questions in order before writing animation code. SKILL.md carries the duration table, the named curves, and the pattern-to-recipe map; this file is the reasoning that picks between them.
      
      ## 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 |
      
      **Novelty budget.** Keep most of a surface familiar: about 90% expected motion (or none) and 10% novel treatment. Do not stack high-novelty beats in consecutive sections; put quiet structure between them.
      
      **One-shot only.** First-run staggers, intro morphs, and login flourishes must not replay on every visit. Gate them with a cookie, local flag, or rewrite so a reload is instant.
      
      ## 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 |
      
      ## 3. What easing should it use?
      
      Two cases the named curves in SKILL.md do not cover:
      
      - **Needs physics feel?** → spring ([spring-animations.md](spring-animations.md))
      - **Constant motion (marquee, spinner)?** → `linear`
      
      Match curve strength to size and frequency: weaker curves (quad, cubic) for small or frequent elements, stronger curves (quint, expo) for large or rare transitions. Full named catalogue at [easing.dev](https://easing.dev/), stronger custom variants at [easings.co](https://easings.co/).
      
      ### 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. A steep curve covers most of its distance in the first third, so the same 200ms reads as significantly faster.
      
      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?
      
      Duration changes perceived performance independently of actual speed:
      
      - A fast-spinning spinner makes loading feel faster (same elapsed time, different perception)
      - `ease-out` at 200ms _feels_ faster than `ease-in` at 200ms: the user sees immediate movement
      - Instant tooltips after the first opens (skip delay and animation) make the whole toolbar feel faster
      
      ## Finding opportunities: where motion is missing
      
      Questions 1 and 2 above judge a candidate someone already proposed. This section is the sweep that produces candidates in the first place: given an interface, where would motion genuinely help? Run every hit back through questions 1 and 2, and expect to reject most of them. A short list of high-conviction opportunities beats a long wishlist, and an opportunity finder that suggests motion everywhere produces exactly the sluggish, over-animated interfaces the rest of this skill exists to prevent.
      
      Sweep these seam classes. The skill is done sweeping when each has either yielded candidates with `file:line` evidence or been explicitly cleared.
      
      | Seam | What it looks like | Where to grep |
      |---|---|---|
      | Feedback gap | A pressable control with no press state | `onClick` / `onPress` on elements with no `:active`, `active:`, or transition |
      | Teleporting state | Content that swaps, appears, or vanishes with no bridge | `{isOpen &&`, `{show`, `display: none` toggles, accordions and collapses with no height or opacity transition |
      | Missing spatial story | A surface with no connection to what opened it | Popovers, menus, and panels with no `transform-origin` at the trigger; dismissable surfaces that exit by a different path than they entered |
      | Group entrance | An occasionally-viewed grid or list that pops in whole | `.map(` renders on first-load surfaces, where a 30-50ms stagger would help |
      | Gesture seam | Draggable or swipeable elements that snap with no physics | Drag and pointer handlers with no spring, no velocity-based dismissal, no rubber-banding at boundaries |
      | Flat delight moment | Rare, high-emotion states rendered without any motion | First-run, empty, success, and completion components |
      
      The last row is where the delight budget lives, and it is the only tier where bounce, generous stagger, or a longer beat are welcome.
      
      **Report both halves.** A discovery pass caps at five to seven suggestions ordered by leverage, and it must also list two to five places deliberately *not* suggested, each naming the question that killed it ("command palette open/close: keyboard-initiated, 100+/day, never animate"). The rejected list is what separates a discovery pass from an animation wishlist. Where the interface is already close to right, saying so is the correct result, not a failure.
      
    • discovery-workflow.md 1.7 KB
      # Discovery workflow
      
      Use this branch when the request is "where should this animate", not "animate this". Every other mode starts from motion that exists; this one starts from its absence. It reports and never implements: hand a surviving suggestion back to the implementation workflow in SKILL.md to build it.
      
      ```text
      Discovery progress:
      - [ ] Step 1: Recon the stack, existing motion tokens, and product personality
      - [ ] Step 2: Sweep every seam class
      - [ ] Step 3: Gate each candidate
      - [ ] Step 4: Report survivors and rejections
      ```
      
      1. **Recon.** Identify the motion library (if any), the easing and duration tokens already in use, and how often each surface is visited. Suggestions extend the existing vocabulary rather than introducing a parallel one, and a dense dashboard earns fewer and subtler suggestions than a playful consumer app.
      2. **Sweep.** Walk the seam table in the decision framework loaded by SKILL.md, which carries the grep signature for each. Clear a seam explicitly rather than skipping it silently.
      3. **Gate.** Run each candidate through questions 1 and 2 of the same file: frequency, then purpose. "It looks cool" is not a purpose. Most candidates die here, which is the point.
      4. **Report.** Order suggestions by impact, each with `file:line`, what happens today, the named purpose, the frequency tier, and exact values (property, duration, curve) drawn from the core easing and transition tables in SKILL.md. Include rejected candidates only when the reason clarifies a likely alternative. Close with which single suggestion has the highest leverage.
      
      Where the interface already carries the right amount of motion, say so. That is the correct result for a well-built UI, not an empty report.
      
      
    • gesture-drag.md 11.6 KB
      # 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)
      - [Grab offset](#grab-offset)
      - [Axis commitment](#axis-commitment)
      - [Multi-touch protection](#multi-touch-protection)
      - [Friction vs hard stops](#friction-vs-hard-stops)
      - [Rotary drag](#rotary-drag)
      - [Detents and snapping](#detents-and-snapping)
      - [Swipe-to-dismiss pattern](#swipe-to-dismiss-pattern)
      - [Carousel axis](#carousel-axis)
      
      ## 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.
      
      ## Grab offset
      
      Record where inside the element the pointer landed, and hold that offset for the whole drag:
      
      ```ts
      let grabY = 0;
      
      function onPointerDown(e: PointerEvent) {
        const r = el.getBoundingClientRect();
        grabY = e.clientY - r.top; // where in the element the finger actually is
      }
      
      function onPointerMove(e: PointerEvent) {
        setY(e.clientY - grabY); // not e.clientY, and not a centred element
      }
      ```
      
      Positioning from `e.clientY` alone snaps the element's top (or its centre, with a `-50%` translate) to the pointer the instant the drag begins. The element jumps under the finger before it has moved, which breaks 1:1 tracking at the only moment the user is watching for it. Grab a sheet by its handle and it should stay gripped by the handle.
      
      ## Axis commitment
      
      Track from `pointerdown`, but do not claim an axis until the pointer has travelled about 10px:
      
      ```ts
      let axis: "x" | "y" | null = null;
      
      function onPointerMove(e: PointerEvent) {
        const dx = e.clientX - startX;
        const dy = e.clientY - startY;
      
        if (!axis) {
          if (Math.hypot(dx, dy) < 10) return; // too early to tell
          axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
        }
        if (axis !== "x") return; // this handler owns horizontal only
        // drag...
      }
      ```
      
      Deciding on the first `pointermove` reads noise: the first few pixels of a vertical scroll usually carry some horizontal drift, so a swipe-to-dismiss row inside a scrolling list steals the gesture and the list stops scrolling. Once committed, hold the axis until `pointerup`; re-deciding mid-drag makes the element stutter between behaviours.
      
      This is the custom-handler counterpart to the declarative fix under Carousel axis. `touch-action` tells the browser which axis it may keep, which settles native scrolling; it does nothing for a handler resolving the ambiguity itself.
      
      ## 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.
      
      ## Rotary drag
      
      For knobs and dials, track the *angle* from the control's centre, not the pointer delta. Reading `dx`/`dy` makes the knob respond to how far the pointer moved rather than where it moved to, so the grip slides off the moment the user circles wide.
      
      The trap is the wrap at ±180°. `atan2` jumps from `π` to `-π` in one frame, and an unguarded subtraction sends the value flying a full turn. Normalise every delta into `(-π, π]` before accumulating:
      
      ```ts
      const TWO_PI = Math.PI * 2;
      
      function angleFrom(el: HTMLElement, e: PointerEvent): number {
        const r = el.getBoundingClientRect();
        return Math.atan2(e.clientY - (r.top + r.height / 2), e.clientX - (r.left + r.width / 2));
      }
      
      let last = 0;
      let turns = 0; // accumulated rotation in radians, unbounded
      
      function onPointerDown(e: PointerEvent) {
        el.setPointerCapture(e.pointerId); // see Pointer capture
        last = angleFrom(el, e);
      }
      
      function onPointerMove(e: PointerEvent) {
        const now = angleFrom(el, e);
        // Shortest way round, so the ±180 seam never registers as a full turn.
        const delta = ((now - last + Math.PI * 3) % TWO_PI) - Math.PI;
        turns += delta;
        last = now;
        setValue(clamp(turns / TWO_PI));
      }
      ```
      
      Two things fall out of this. A knob with a limited range (a volume dial, not an endless encoder) needs the *accumulated* value clamped, never the per-frame angle, or the knob detaches from the pointer at the limit and jumps back when the user reverses. And a knob whose travel is under one full turn should apply Boundary damping at each end, exactly as a linear slider does.
      
      ## Detents and snapping
      
      A ruler picker, tick slider, or segment scrubber has discrete stops. Do not snap during the drag: the value should follow the pointer continuously, and settle to the nearest detent only on release. Snapping live makes the control feel like it is fighting the finger.
      
      ```ts
      function onRelease(value: number, velocity: number) {
        // Project where momentum would carry it, then snap that (see Momentum projection).
        const projected = value + velocity * 0.15;
        const target = Math.round(projected / STEP) * STEP;
        animate(value, target, { type: "spring", stiffness: 500, damping: 40 });
      }
      ```
      
      Snapping the *projected* landing point rather than the release position is what makes a flick feel like it threw the control several notches, instead of dropping it at the nearest tick.
      
      The tick marks themselves carry the feedback during the drag. Scale or darken the tick under the indicator as it passes, on `transform` and `color` only. This is the visual stand-in for the haptic click a physical detent would give, and without it a continuous drag over a ruler reads as a smooth slider that happens to be drawn with lines. On a device that supports it, pair the passing tick with `navigator.vibrate(1)`.
      
      ## Swipe-to-dismiss pattern
      
      Velocity decides; distance is only the tie-breaker. Sign both against the dismissal direction, so "toward dismissal" is positive on each.
      
      ```ts
      const FLICK = 0.11; // px/ms, matches Sonner
      
      // offset and velocity are both signed along the drag axis:
      // positive = moving toward dismissal, negative = back toward rest.
      function handleSwipeEnd(offset: number, velocity: number) {
        if (Math.abs(velocity) > FLICK) {
          // A flick decides on its own, in whichever direction it points.
          if (velocity > 0) animateOut(velocity);
          else springBack(velocity);
          return;
        }
        // Released slowly: position is all the intent there is.
        if (offset > THRESHOLD) animateOut(velocity);
        else springBack(velocity);
      }
      ```
      
      The common bug is `distance > THRESHOLD || velocity > 0.11` against an unsigned velocity. A sheet dragged 80% closed and then flicked back toward open passes the distance test and dismisses anyway, which is the user's cancel gesture doing the opposite of what they asked. Checking magnitude first and sign second is what makes a reversal cancel.
      
      The exit continues 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, and into `springBack` too: a cancelled flick that starts from zero reads as a bounce the user did not cause.
      
      ## Carousel axis
      
      A horizontal scroller that also moves the page is an axis fight.
      
      **CSS `overflow-x` / scroll-snap:** let the browser pan horizontally, and stop horizontal overscroll from triggering Back:
      
      ```css
      .carousel {
        overflow-x: auto;
        touch-action: pan-x;
        overscroll-behavior-x: contain;
      }
      ```
      
      **JS-driven** (Embla, Swiper, Keen): those libraries set `touch-action: pan-y` so the page still scrolls vertically while they handle the horizontal drag. Do not override to `pan-x`.
      
      
    • interface-sfx.md 2 KB
      # Interface SFX
      
      Sparse confirmation sounds for rare, high-stakes, or physical-feeling interactions.
      
      ## Scope
      
      - **IS:** sparse confirmation sounds for rare, high-stakes, or physical-feeling interactions (toggle lock, payment confirm, drag release, success moment).
      - **IS NOT:** background music, autoplay, looping UI beds, or replacing visual feedback.
      
      ## Rules
      
      1. **Unlock from a user gesture.** Create or resume `AudioContext` only inside a click, tap, or keydown handler. Never on page load or in `useEffect` without a gesture.
      2. **Stay quiet.** Keep volume well below content audio. Respect system mute and tab mute; if the tab is muted, do not play.
      3. **Additive only.** Pair every sound with visual feedback (scale, color, icon swap). Sound confirms what the user already sees; it never carries the message alone.
      4. **Same frequency rule as motion.** High-frequency actions stay silent: typing, hover, scrolling, list navigation, repeated toggles. If the user does it dozens of times per session, no sound.
      5. **Honor `prefers-reduced-motion`.** Treat it as a signal to skip optional SFX unless the user explicitly enabled sounds in settings.
      6. **Keep clips tiny.** Tens of milliseconds, soft attack, no peak that clips. One-shot, non-looping.
      7. **One owner.** Route all playback through a tiny `play(id)` helper (preload, volume, mute checks, reduced-motion gate). No ad-hoc `new Audio()` at call sites.
      
      ## Implementation sketch
      
      ```javascript
      let ctx;
      
      function unlockAudio() {
        if (!ctx) ctx = new AudioContext();
        if (ctx.state === 'suspended') ctx.resume();
      }
      
      function playSfx(id) {
        if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
        if (!ctx || ctx.state !== 'running') return;
        // fetch decoded buffer for id, set gain ~0.1-0.2, play once
      }
      ```
      
      Wire `unlockAudio` to the first meaningful interaction on the surface that uses SFX.
      
      ## Sources
      
      Informed by Craft (gustavo-fior Interface SFX) and Raphael Salaja's writing on web sound. Original prose; not copied.
      
    • live-tuning.md 4.5 KB
      # Live tuning
      
      The reverse-engineer workflow runs backwards: record a motion you admire, then fit a curve to it. This is the forward version, for when there is no reference to copy and the table value is contested. Tune against the running component instead of guessing, reloading, and guessing again.
      
      Start in DevTools. It is already open, it costs nothing, and it covers every bezier in the easing defaults table.
      
      ## Contents
      
      - [When this is worth it](#when-this-is-worth-it)
      - [The bezier editor](#the-bezier-editor)
      - [Retiming in the Animations panel](#retiming-in-the-animations-panel)
      - [What DevTools cannot do](#what-devtools-cannot-do)
      - [Baking the value back](#baking-the-value-back)
      
      ## When this is worth it
      
      - **The value is contested.** Two people disagree on whether a drawer should be 300ms or 400ms and neither can win the argument from a table.
      - **The component is hard to reach.** A toast that needs a form submitted, a sheet three navigations deep. Each rebuild round trip costs more than the setup does once, and an HMR reload loses the state that got you there.
      - **The motion is multi-phase.** Stagger offset, blur ramp, and settle interact, so three numbers guessed one reload at a time converge slowly.
      
      Not for picking a button press duration. The easing defaults table answers that in one line.
      
      ## The bezier editor
      
      Chrome, Edge, and Firefox render a small curve swatch next to any `transition-timing-function` or `animation-timing-function` in the Styles (or Rules) pane. Click it for a draggable cubic-bezier editor.
      
      Edits apply live with no rebuild, so retrigger the interaction and watch it under the new curve. The editor emits the literal (`cubic-bezier(0.22, 1, 0.36, 1)`), which is what goes back into source.
      
      Two things that waste time otherwise:
      
      - The swatch only exists once the property is valid. On an element with no timing function yet, add the declaration in the `element.style` pane first and the swatch appears.
      - Start from the table value, not a built-in preset. Opening on `cubic-bezier(0.22, 1, 0.36, 1)` gives you something to judge against; opening on `ease` means finding the table value by hand.
      
      Safari has no bezier editor. Tune in Chrome, verify in Safari.
      
      ## Retiming in the Animations panel
      
      The panel's slow-motion playback is a debugging tool and belongs to the Validation workflow. Two of its controls are tuning tools:
      
      - **Drag a bar's edges** to change a duration or delay live, then replay. Faster than editing per-item delays for a stagger you are trying to feel out.
      - **Read the captured group** to see every element's delay and duration side by side. This is the quickest way to recover the timing of a stagger you did not write, including one a library is generating.
      
      ## What DevTools cannot do
      
      - **Springs.** No spring editor exists. Reach for the presets and the `visualDuration`/`bounce` framing in `spring-animations.md`: they are perceptual, so they land close on the first try, and a wrong spring usually needs one parameter moved rather than a search.
      - **Composing multi-phase choreography.** The panel retimes what already fired; it will not let you build the phases against a shared playhead.
      
      If a project hits those two often enough to matter, a control-panel library (DialKit, Leva, Tweakpane) earns a dev dependency: a spring control returns a Motion `TransitionConfig` that drops straight into `animate()`, and a timeline dock composes phases. That is a standing decision about the project, not something to install mid-task for one curve.
      
      ## Baking the value back
      
      A tuning surface is a measuring instrument, not a delivery mechanism.
      
      - A DevTools edit lives only in that tab and dies on navigation. Paste the literal into source before you believe it.
      - Put it next to the other timing constants, so the next person sees it beside the values it has to agree with.
      - A control panel leaves more behind than the dock: replace every sampled binding with the real animation, then remove the panel, its root, and the dependency. Framework roots hide themselves in production builds, but a vanilla root does not, and a forgotten one ships a control panel to users.
      - Re-check the result against the ten standards. What felt right after ten iterations on a fast laptop still has to clear no layout-property transitions, `prefers-reduced-motion` handled, and interruption retargeting rather than restarting.
      
      Tune on the real surface. A curve dialled on an isolated demo reads differently against the distance, size, and neighbours of the actual component, and how often the user sees it moves the answer more than any parameter does.
      
    • measurement-guide.md 4.1 KB
      # 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.
      
    • performance-deep-dive.md 8.5 KB
      # Performance Deep Dive
      
      Advanced performance guidance beyond the quick rules in SKILL.md.
      
      ## Contents
      - [Property cost tiers](#property-cost-tiers)
      - [CSS vs JS animations](#css-vs-js-animations)
      - [Long tasks during animation](#long-tasks-during-animation)
      - [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)
      
      ## Property cost tiers
      
      Every animatable property enters the browser's Layout, Paint, Composite pipeline at one of three points, and the cost differs by an order of magnitude:
      
      | Tier | Properties | Cost |
      |---|---|---|
      | Composite only | `transform`, `opacity` (plus `filter`, `clip-path`, `background-color` in current Chrome/Firefox) | Cheapest; the browser promotes these to their own layer |
      | Paint + Composite | `box-shadow`, `border-radius`, `color` | No re-measuring, but an expensive redraw every frame |
      | Layout + Paint + Composite | `width`, `height`, `padding`, `margin`, `top`, `left`, `border-width` | Most expensive; layout recalculates every frame |
      
      The paint tier is the one people miss because it doesn't look like layout. Swap down a tier:
      
      | Instead of animating | Animate |
      |---|---|
      | `width`/`height`/`padding` to grow or shrink | `scale()` |
      | `margin`/`top`/`left` to move | `translate()` (percentages are relative to the element's own size) |
      | `box-shadow` | `filter: drop-shadow(...)` |
      | `border-radius` | `clip-path: inset(0 round 50px)` |
      
      A layout property may not visibly drop frames on an element with `position: absolute` or few children, but the `scale()` version looks identical and cannot regress on a slower device; take the one with no downside.
      
      ## 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.
      
      ## Long tasks during animation
      
      The rule above holds because `transform` and `opacity` animate on the compositor thread, which keeps running while the main thread is blocked. Everything else shares one thread: style recalculation, layout, paint, and every line of JS including `requestAnimationFrame` callbacks and Motion's `x`/`y`. That thread is also the one your application code runs on. The budget there is roughly 10ms of the 16.6ms frame at 60Hz, and half that at 120Hz. A task over 50ms is a long task: any concurrent main-thread animation visibly stutters and input goes unanswered for its duration.
      
      So when motion janks *only sometimes* (on open, on first run, during navigation, while data lands), suspect the work sharing the tick, not the animation code. Moving to CSS/WAAPI is the fix when the animation can be expressed that way; when it can't (drag, springs, physics, choreography), fix the scheduling instead.
      
      **1. Don't co-schedule.** Starting an animation and expensive work in the same tick makes the entrance pay for the work: a modal that mounts a large tree, a drawer that parses its contents, a tab that fetches on click. Start the motion, let a frame land, then do the work, or defer the work to `transitionend`/`onAnimationComplete` so it runs after the motion finishes.
      
      **2. Chunk what can't be deferred,** against a time budget rather than a fixed item count, so the cost tracks the device instead of your laptop:
      
      ```ts
      const yieldToBrowser = (): Promise<unknown> =>
        typeof scheduler !== "undefined" && "yield" in scheduler
          ? scheduler.yield()
          : new Promise((resolve) => setTimeout(resolve, 0));
      
      async function inChunks<T>(items: T[], work: (item: T) => void) {
        let start = performance.now();
        for (const item of items) {
          work(item);
          if (performance.now() - start > 5) {   // leave the rest of the frame to the animation
            await yieldToBrowser();
            start = performance.now();
          }
        }
      }
      ```
      
      `scheduler.yield()` resumes ahead of other pending tasks rather than behind them, but it is Chromium-only today, hence the `setTimeout` fallback. Use `await new Promise(requestAnimationFrame)` instead when the chunked work feeds the animation itself and must resume in step with frames.
      
      Yielding does not make the work faster; the total is unchanged. It lets frames paint and input dispatch between the pieces, which is the entire perceived difference. If the work genuinely cannot be split (one large parse, one synchronous layout of a huge tree), it belongs in a worker or on the server; no amount of animation tuning hides it.
      
      ## 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.
      
      One more reason to reach for the string form: the individual shorthands (`x`, `y`, `scale`, `rotate`) are implemented with CSS variables and driven from `requestAnimationFrame`, so they are not hardware-accelerated. That's harmless normally, but motion that runs *while* the main thread is busy (page navigation, tab switches during data loading, hydration) drops frames exactly then. Vercel's dashboard hit this with a shared-layout tab highlight that janked during navigation; the fix was moving it to CSS. When an animation must survive a busy main thread, animate the full `transform` string, or move it to CSS/WAAPI.
      
      ## 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.
      
    • review-format.md 8.8 KB
      # 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 focus and repeated actions must respond immediately. Flag motion that delays task completion or creates distracting repeated travel; a brief nonblocking transition is not automatically a defect.
      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.9-0.96)` 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.** Inspect generated hover gating, including Tailwind v4's built-in media query. Exercise reduced-motion behavior and the same keyboard/touch task. Flag spatial motion without an appropriate reduced-motion alternative.
      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** (disruptively repeated or without a purpose).
      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 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` with `transition-duration: 0s` | Buttons must feel responsive to press |
      | `transform-origin: center` on popover | `transform-origin: var(--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 |
      | Same bouncy spring on open and close | Bounce the open only; damp the close and roughly halve its duration |
      | Value snapped to its detent during the drag | Follow the pointer continuously; snap the projected landing point on release |
      | CSS carousel scrolls the page | `touch-action: pan-x` and `overscroll-behavior-x: contain`; leave JS libraries on `pan-y` |
      | 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**: pointer/hover gating.
      
      Close with a decision, citing `file:line`:
      
      - **Block**: any feel-breaking regression, motion that delays keyboard or repeated actions, `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.
      
      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, reduced-motion checks), see the Validation section in `SKILL.md`.
      
    • scroll-animations.md 7.1 KB
      # Scroll Animations
      
      Scroll-triggered reveals and scroll-driven (scrubbed) motion. Scroll is the most abused trigger in web motion, so this reference is half restraint, half implementation, in that order. The scrollbar belongs to the user: motion may respond to scrolling, but must never take it over or make content wait.
      
      ## Contents
      - [Gate: should this scroll animation exist?](#gate-should-this-scroll-animation-exist)
      - [Two kinds: triggered vs scrubbed](#two-kinds-triggered-vs-scrubbed)
      - [Triggered reveals](#triggered-reveals)
      - [Scrubbed animation](#scrubbed-animation)
      - [Parallax](#parallax)
      - [Sticky and scrollytelling sections](#sticky-and-scrollytelling-sections)
      - [Never hijack scroll](#never-hijack-scroll)
      - [Performance](#performance)
      
      ## Gate: should this scroll animation exist?
      
      Walk this before writing any code:
      
      ```
      Is this inside a product (dashboard, app, tool)?
      ├── Yes → No scroll animation. Users scroll product UI dozens of times a
      │         session; content appearing late reads as lag, not delight.
      └── No, it's a marketing surface (landing page, blog, docs)
          ├── Is the element in the initial viewport (above the fold)?
          │   └── Yes → Don't scroll-reveal it. Use a one-time intro animation
          │             or nothing. The hero must never wait for a scroll event.
          ├── Are you about to reveal EVERY section?
          │   └── Yes → Cut it to 2-4 moments. If everything animates, nothing
          │             stands out; each reveal devalues the next.
          └── Does it explain, pace, or emphasize something specific?
              ├── Yes → Build it (rules below).
              └── No ("it looks cool") → The best animation is no animation.
      ```
      
      Marketing pages are the packaging of the product: they have earned slower, more expressive motion because they are seen rarely. That freedom is the reason to be selective, not a license to animate everything.
      
      ## Two kinds: triggered vs scrubbed
      
      Every scroll animation is one of these; the wrong choice is unfixable by tuning:
      
      - **Triggered reveal.** Crossing a threshold *starts* a normal animation that then runs on its own clock (easing plus duration). For "fade in as it enters the viewport".
      - **Scrubbed.** Scroll position *is* the clock; progress maps directly to animation progress and reverses when the user scrolls back. For progress bars, parallax, sticky sequences.
      
      A scrubbed animation has **no duration and no easing**: the user's hand is both. Adding a duration to scrubbed motion makes it lag behind the scrollbar, the same disconnected feeling as a spring during a drag.
      
      ## Triggered reveals
      
      - **Reveal once. Never re-animate on scroll-up.** Intro animations run one time; replaying on every pass turns delight into a tic and makes content flicker during normal reading. Unobserve after firing (or `once: true` in Motion's `useInView`).
      - **The recipe:** `opacity: 0` plus `translateY(10-16px)` settling to rest, with a strong ease-out (entering elements always ease out; the fast start reads as responsive). 400-600ms is right for marketing; product-speed 200ms reveals look nervous on a landing page.
      - **Trigger early.** Start the animation when the element is roughly 10-20% into the viewport (`rootMargin: "0px 0px -10% 0px"`), so it plays *as* the user arrives, not after they have stopped and stared at a blank slot.
      - **Stagger like a wave, not a metronome.** Sibling reveals offset by roughly 80-120ms, with delay and distance varied by importance: the heading leads, supporting text follows, the least important item can just fade with no movement. Uniform stagger kills hierarchy.
      - **Content survives without JS.** The un-animated state is *visible*; JS adds the hidden initial state right before animating. A page of `opacity: 0` sections behind a broken script is the worst failure mode a marketing page has.
      - **One entrance per container.** Don't reveal a section *and* stagger its children; pick one.
      
      Implementation: `IntersectionObserver` (or Motion's `useInView`) toggling a class. Never a scroll listener; it fires per frame on the main thread for work a threshold check does once.
      
      ## Scrubbed animation
      
      Preference order, and why:
      
      1. **CSS scroll-driven animations:** `animation-timeline: view()` (the element's own viewport progress) or `scroll()` (container progress). They run off the main thread, stay hooked to the scrollbar even while the page is busy loading images, and cost no JS. Progressive-enhance: wrap in `@supports (animation-timeline: view())` with the no-animation state as fallback.
      2. **Motion's `useScroll` plus `useTransform`:** when progress must feed React logic or compose with springs and gestures. This runs on the main thread via `requestAnimationFrame`: fine normally, drops frames under load.
      3. **A raw scroll listener writing React state: never.** A re-render per scrolled pixel.
      
      ```css
      @supports (animation-timeline: view()) {
        .figure {
          animation: reveal linear both;
          animation-timeline: view();
          animation-range: entry 0% cover 40%;
        }
      }
      ```
      
      `linear` is correct here and only here: the scrubbed timeline's pacing comes from the user's hand, and any curve would distort the 1:1 mapping.
      
      The `@supports` wrapper is not optional: `animation-timeline` is still not Baseline, so without it the element sits at its keyframe start forever in browsers that ignore the property. The unwrapped rule must leave the element in its final, readable state.
      
      ## Parallax
      
      Parallax is depth seasoning, and heavy-handed parallax is the fastest way to make a page feel dated:
      
      - **Keep the differential at or under roughly 15%** of scroll distance between layers. Enough to read as depth; more reads as content swimming.
      - **Transform only**, scrubbed (no duration), decorative elements only: never body text, never anything the user needs to read while it moves.
      - Skip it on mobile: short viewports and momentum scrolling turn subtle parallax into jitter.
      
      ## Sticky and scrollytelling sections
      
      A section that pins while scroll drives a sequence is an **explanation** device; it earns its scroll length only if each increment reveals a step of a story. Rules: progress maps monotonically to the sequence (scrolling back rewinds it); keep the pinned length at or under roughly 2-3 viewport heights, because trapped-feeling sticky sections are where users close tabs; and the section must be skippable by simply continuing to scroll. Never block or slow the scrollbar to force the story.
      
      ## Never hijack scroll
      
      No scroll-jacking, no rewriting wheel deltas, no "one wheel tick = one full-screen slide". Smooth-scroll libraries that re-implement scrolling on the main thread trade native responsiveness for a float many users read as lag. If you add `scroll-behavior: smooth` for anchor links, keep it to that:
      
      ```css
      html { scroll-behavior: smooth; }
      ```
      
      ## Performance
      
      The golden rule holds: **animate only `transform` and `opacity`**. A scrolling page is the worst place for layout-triggering properties, since Layout and Paint work stacks on top of the scroll itself. Add `will-change: transform` on scrubbed elements only (they animate for the whole scroll, so the dedicated layer pays for itself; on one-shot reveals it's wasted memory). Keep any animated `blur()` at or under 20px.
      
    • spring-animations.md 6.7 KB
      # 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)
      - [Asymmetric spring character](#asymmetric-spring-character)
      - [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 });
      ```
      
      ## Asymmetric spring character
      
      Open and close differ in **stiffness, not just duration**: when an element earns bounce, the bounce belongs to the open and the close stays critically damped. Bouncing both directions is the most common reason a well-built morph still feels cheap.
      
      Measured on a production container morph (frame-by-frame at 60fps):
      
      | Direction | Time to extreme | Overshoot | Fitted spring | At rest |
      |---|---|---|---|---|
      | Open | 284ms | 121% of travel | `stiffness: 155, damping: 11` (ζ 0.44) | 584ms |
      | Close | 185ms | ~102% of travel | `stiffness: 620, damping: 36` (ζ ~0.75) | 300ms |
      
      The close is twice as fast *and* nearly four times as stiff. Its 2% undershoot is below the perceptual threshold, so a plain `cubic-bezier(0.32, 0.72, 0, 1)` substitutes for it cleanly.
      
      This widens the "bounce only after momentum" default above rather than replacing it. A menu that merely faded in still should not bounce. A container the user watched push outwards has enough implied mass to justify a settle, and that is the one case where the default reads as too conservative.
      
      For measuring asymmetry off a recording rather than choosing it, `choreography.md` covers reading the two directions out of the frame timeline.
      
      ## 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 "motion/react";
      
      // 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 }
        }
      />
      ```
      
    • svg-animation.md 5.4 KB
      # SVG Animation
      
      Recipes for animating vector art: line drawing, rotation, path morphing, shakes, and ambient life. SVG has its own coordinate system and its own transform-origin rules, so HTML habits produce wrong results here.
      
      ## Contents
      - [Fundamentals](#fundamentals)
      - [Line drawing (self-drawing stroke)](#line-drawing-self-drawing-stroke)
      - [Rotation and transform-origin (the SVG trap)](#rotation-and-transform-origin-the-svg-trap)
      - [Path morphing](#path-morphing)
      - [Shakes and multi-step motion](#shakes-and-multi-step-motion)
      - [Ambient life](#ambient-life)
      - [Performance for busy SVG scenes](#performance-for-busy-svg-scenes)
      
      ## Fundamentals
      
      - SVG is coordinate-based with no document flow; unpositioned elements stack at `(0,0)`.
      - `viewBox="minX minY width height"` is the camera: it enables responsive scaling and keeps animation values consistent at any display size.
      - Path commands: `M` move (no draw), `L` line, `Z` close; uppercase is absolute, lowercase relative. Close paths with `Z` or the point where start meets end shows an awkward corner.
      - Degenerate shapes don't render at all: `width="0"`, `r="0"`, or a line whose start equals its end vanish entirely (unlike `opacity: 0`, where the shape still exists).
      - Put `overflow: visible` on the `<svg>` so overshoot and scale don't clip. Nest `<g>` groups to layer independent transforms on one element.
      
      ## Line drawing (self-drawing stroke)
      
      Reveal a stroke as if it's being drawn by animating `stroke-dashoffset`:
      
      1. Set `stroke-dasharray` so the dash equals the full path length and the gap is large (only one dash shows).
      2. Offset by the path length to hide it.
      3. Animate the offset back to `0` to draw it in.
      
      ```css
      path {
        stroke-dasharray: 1px 1.1px;
        stroke-dashoffset: 1px;
        animation: draw 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
      }
      @keyframes draw { to { stroke-dashoffset: 0; } }
      ```
      
      - `pathLength="100"` on the path normalizes its length so you work in round numbers and can share values across paths of different real lengths.
      - `animation-fill-mode: forwards` is required or the shape snaps back to hidden when the animation ends.
      - Stagger multiple strokes with `animation-delay` (a checkmark waits for its box to finish drawing).
      - `stroke-linecap: round` gotcha: rounded caps extend past the mathematical dash, so make the gap slightly larger than the dash (`1px` dash, `1.1px` gap) or the caps peek through while the line should be hidden.
      
      ## Rotation and transform-origin (the SVG trap)
      
      `transform-origin` in SVG defaults to the viewBox `(0,0)`, and `center` means the center of the viewBox, not the element. Fix it one of two ways:
      
      ```css
      /* Preferred: make origin relative to the element's own box (HTML-like) */
      .el { transform-box: fill-box; transform-origin: center; }
      
      /* Or: keep viewBox coordinates and rotate around a specific point */
      .hand { transform-origin: 50px 50px; } /* clock center of a 100x100 viewBox */
      ```
      
      For a zero-thickness line's bounding box, `transform-origin: 0% 100%` hits the start point (the zero dimension ignores its percentage).
      
      **Motion for React overrides a `transformOrigin` set in `style` on SVG elements back to `50% 50%`.** Set it in the `initial` prop instead:
      
      ```jsx
      <motion.g
        initial={{ transformOrigin: "76.3px 69.5px" }}
        style={{ transformBox: "view-box" }}
        animate={{ rotate: 360 }}
      />
      ```
      
      Use `transform-box: view-box` plus a pixel `transformOrigin` to rotate a group around a distant point (e.g. decorations orbiting a clock's center).
      
      ## Path morphing
      
      Animate a path's `d` between two shapes; this only works when both paths share point structure:
      
      ```jsx
      const progress = useMotionValue(0);
      const d = useTransform(progress, [0, 1], [openPath, closedPath]);
      // <motion.path d={d} />
      ```
      
      If the two paths differ in structure, interpolate with the `flubber` library instead.
      
      ## Shakes and multi-step motion
      
      Keyframe arrays fit shakes, pulses, and press feedback: decaying, alternating-sign values.
      
      ```jsx
      // bell shake: rotate keyframes, large to small, alternating
      animate={{ rotate: [0, 20, -15, 12.5, -10, 10, -7.5, 7.5, -5, 5, 0] }}
      // press feedback: compress, overshoot, settle
      animate={{ transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] }}
      ```
      
      Put the rotate on a wrapping `<g>` so nested decorations shake for free.
      
      ## Ambient life
      
      Make idle scenes feel alive with barely perceptible looping motion, and use **non-syncing durations** so layers never line up; that's what makes it read organic instead of mechanical:
      
      ```jsx
      // float: translateY 0 to 1.5px over 3s; rotate: 0 to 2deg over 4s
      transition={{ ease: "easeInOut", repeat: Infinity, repeatType: "reverse" }}
      ```
      
      Give idle and attention loops an initial delay (~2s) so users discover interactions first, and a `repeatDelay` between plays. Pause the loops off-screen (see the IntersectionObserver hook in `performance-deep-dive.md`).
      
      ## Performance for busy SVG scenes
      
      Many simultaneously animating SVG elements, especially with filters, can drop frames. Promote only the animated ones, after you see jank, not preemptively:
      
      ```css
      svg [data-animate]   { will-change: transform, opacity, stroke-dashoffset; contain: layout style paint; }
      svg .filter-animated { will-change: transform; transform: translateZ(0); }
      ```
      
      `contain: layout style paint` isolates an element's rendering so it doesn't repaint siblings; `translateZ(0)` forces a GPU layer for expensive filtered elements. Target `[data-animate]`, not every node; too many GPU layers cost memory.
      
    • transition-recipes.md 23.7 KB
      # CSS Transition Recipes
      
      14 CSS transition patterns. Each includes CSS, HTML hooks, and JS orchestration where needed. All read from a shared `:root` custom properties block.
      
      ## Contents
      
      - [Custom properties](#custom-properties)
      - [Container morph](#container-morph)
      - [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)
      - [Odometer digit roll](#odometer-digit-roll)
      - [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 {
        /* Container morph */
        --morph-open-dur: 580ms;
        --morph-close-dur: 300ms;
        --morph-open-ease: linear(0, 0.45, 0.78, 1, 1.17, 1.21, 1.18, 1.12, 1.05, 1.02, 1);
        --morph-close-ease: cubic-bezier(0.32, 0.72, 0, 1);
        --morph-content-dur: 140ms;
        --morph-content-blur: 3px;
      
        /* Card resize */
        --resize-dur: 300ms;
        --resize-ease: cubic-bezier(0.22, 1, 0.36, 1);
      
        /* Odometer digit roll */
        --odo-dur: 260ms;
        --odo-ease: cubic-bezier(0.22, 1, 0.36, 1);
        --odo-dir: 1; /* 1 = value increased, -1 = decreased */
      
        /* 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;
      }
      ```
      
      ---
      
      ## Container morph
      
      The trigger *becomes* the surface. A button, chip, or pill grows in place into the search field, form, menu, or confirmation it summons, keeping one continuous background and border-radius throughout. No new element appears, so there is nothing for the eye to re-find.
      
      Use this over Menu dropdown or Modal dialog whenever the trigger and the surface can share a shape. Use Card resize instead when the container already exists and only its dimensions change.
      
      Three things happen at once, and the order matters:
      
      | Phase | What | Timing |
      |---|---|---|
      | 1 | Old content fades and blurs out | `0` to `--morph-content-dur` |
      | 2 | Container tweens to the new box | full `--morph-open-dur` |
      | 3 | New content fades and blurs in | starts at `--morph-content-dur` |
      
      Measure the target box before animating: a plain `width: auto` has nothing to interpolate towards. Where `interpolate-size: allow-keywords` is supported you can transition to `auto` and drop the measure step, so check support for your targets before choosing.
      
      ```html
      <div class="t-morph" data-open="false">
        <div class="t-morph-face" data-face="closed"><button>Notify me</button></div>
        <div class="t-morph-face" data-face="open">
          <input placeholder="Email" /><button>Notify me</button>
        </div>
      </div>
      ```
      
      ```css
      .t-morph {
        position: relative;
        overflow: hidden;
        border-radius: 999px;
        transition: width var(--morph-close-dur) var(--morph-close-ease),
                    height var(--morph-close-dur) var(--morph-close-ease);
        will-change: width, height;
      }
      .t-morph[data-open="true"] {
        transition-duration: var(--morph-open-dur);
        transition-timing-function: var(--morph-open-ease);
      }
      
      /* Faces stack, so the container never sees both in flow. */
      .t-morph-face {
        transition: opacity var(--morph-content-dur) ease,
                    filter var(--morph-content-dur) ease;
      }
      .t-morph-face[data-face="open"] { position: absolute; inset: 0; }
      
      .t-morph[data-open="false"] [data-face="open"],
      .t-morph[data-open="true"] [data-face="closed"] {
        opacity: 0;
        filter: blur(var(--morph-content-blur));
        pointer-events: none;
      }
      
      /* Incoming content waits for the box to be most of the way there. */
      .t-morph[data-open="true"] [data-face="open"],
      .t-morph[data-open="false"] [data-face="closed"] {
        transition-delay: var(--morph-content-dur);
      }
      ```
      
      **JS, measure then toggle:**
      
      ```js
      function morph(el, open) {
        const face = el.querySelector(`[data-face="${open ? "open" : "closed"}"]`);
        // Measure the target face off-flow, at its natural size.
        const prev = face.style.cssText;
        Object.assign(face.style, { position: "absolute", visibility: "hidden", width: "max-content" });
        const { width, height } = face.getBoundingClientRect();
        face.style.cssText = prev;
      
        el.style.width = `${width}px`;
        el.style.height = `${height}px`;
        el.dataset.open = String(open);
      }
      ```
      
      **Measured reference.** Tracking a real implementation frame by frame at 60fps: the open reached **121% of its travel** at 284ms (a container **9.8% wider** than its resting width), then settled over a further 300ms. The close reached a ~2% undershoot in 185ms and was visually at rest by 300ms. Expansion was symmetric about the trigger's centre, not anchored to an edge.
      
      `--morph-open-ease` is that overshoot transcribed as a `linear()` curve, so `--morph-open-dur` covers the whole settle even though the morph reads as finished around 300ms. The equivalent spring is `{ stiffness: 155, damping: 11, mass: 1 }` (damping ratio 0.44); the close fits `{ stiffness: 620, damping: 36 }`, near enough to critically damped that the bezier above is indistinguishable. Prefer the spring form when the morph must survive interruption. `spring-animations.md` § Asymmetric spring character covers why only the open bounces.
      
      ---
      
      ## 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;
      }
      ```
      
      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);
      }
      ```
      
      ---
      
      ## 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);
      }
      ```
      
      ---
      
      ## 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);
      }
      ```
      
      ---
      
      ## Menu dropdown
      
      Origin-aware dropdown with open/close animations. JS handles close-state cleanup.
      
      See also: `component-patterns.md` § Popovers and dropdowns for library 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; }
      ```
      
      **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);
      }
      ```
      
      **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));
      }
      ```
      
      **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));
      }
      ```
      
      **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); }
      ```
      
      **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");
      }
      ```
      
      ---
      
      ## Odometer digit roll
      
      Roll each changed digit vertically, in the direction the value moved: up for an increase, down for a decrease. Use this over Number pop-in when the number is being *driven* by the user (steppers, sliders, scrubbers, quantity controls), where direction is the feedback. Keep pop-in for values that arrive on their own, where there is no direction to convey.
      
      Two rules keep it readable. Only re-render the digits that actually changed, or a 199 to 200 tick rolls all three and reads as noise. And set `font-variant-numeric: tabular-nums`, or the row re-flows on every tick and the roll turns into a jitter.
      
      ```html
      <span class="t-odo" style="--odo-dir: 1">
        <span class="t-odo-slot"><span class="t-odo-digit">4</span></span>
        <span class="t-odo-slot"><span class="t-odo-digit" data-rolling>1</span></span>
      </span>
      ```
      
      ```css
      .t-odo {
        display: inline-flex;
        font-variant-numeric: tabular-nums;
      }
      .t-odo-slot {
        display: inline-block;
        overflow: hidden;      /* the window the digit rolls through */
        height: 1em;
        line-height: 1em;
      }
      
      @keyframes odo-roll {
        from {
          transform: translateY(calc(var(--odo-dir) * 1em));
          opacity: 0;
        }
      }
      
      .t-odo-digit[data-rolling] {
        display: block;
        animation: odo-roll var(--odo-dur) var(--odo-ease) both;
      }
      ```
      
      **JS, roll only what changed:**
      
      ```js
      function setOdometer(el, next, prev) {
        el.style.setProperty("--odo-dir", next > prev ? 1 : -1);
        const a = String(prev).padStart(String(next).length, " ");
        const b = String(next);
        el.innerHTML = [...b]
          .map((d, i) => {
            const rolling = d !== a[i] ? " data-rolling" : "";
            return `<span class="t-odo-slot"><span class="t-odo-digit"${rolling}>${d}</span></span>`;
          })
          .join("");
      }
      ```
      
      The outgoing digit is dropped rather than animated out. At 260ms with the slot clipping, the eye reads the incoming digit as having pushed the old one away; animating both doubles the work for no visible gain.
      
      ---
      
      ## 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);
      }
      ```
      
      **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;
      }
      ```
      
      **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);
      }
      ```
      
      **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);
      }
      ```
      
    • vocabulary.md 12.4 KB
      # 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.
      - **Container morph**: A trigger grows in place into the surface it summons, so the button becomes the search field or form instead of opening one next to it.
      - **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).
      - **Detent**: A discrete stop a control settles onto when released, like the notches on a ruler picker or tick slider.
      - **Peripheral de-emphasis**: Blurring and fading everything around the focused item instead of dimming the whole page, so the set stays visible but recedes.
      - **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.
      - **Odometer roll**: Digits rolling vertically in the direction the value moved, up for an increase and down for a decrease, like a car's odometer.
      - **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.
      
  • scripts
    • extract_frames.py 4.3 KB
      #!/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()
      
    • fit_curves.py 7.6 KB
      #!/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()
      
    • track_motion.py 7.6 KB
      #!/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()
      
  • SKILL.md 23 KB
    ---
    name: ui-animation
    description: Builds, reviews, and measures UI motion, including springs, gestures, scroll effects, curve fitting from recordings, and sparse interface sound. Use when asked to "add animation", "match this easing", "reverse engineer this motion", "add a click sound", or find animation opportunities. For action semantics use product-design; for visual layout use ui-design.
    ---
    
    # UI Animation
    
    - **IS:** designing, implementing, reviewing, debugging UI motion (springs, gestures, drag, easing, CSS transitions, keyframes, Motion), sweeping an interface for the moments that would genuinely benefit from motion, measuring motion from a recording (extract frames, track, fit curves) to emit code plus a handoff spec, naming a described motion effect (reverse-lookup vocabulary), and gating sparse interface sound.
    - **IS NOT:** choosing overall visual direction, palettes, or typography (use `ui-design` Direction mode), auditing a whole page's UI quality (use `ui-design` Audit mode), or named text-effect specs (use the external `animate-text` skill where installed).
    
    ## Routing boundary
    
    `product-design` owns action semantics, scope, reversibility, and contested state choices. `ui-design` builds and styles those states. `ui-animation` owns timing, gestures, and measured motion. A routine missing loading or error state stays with the UI build; a gesture replacing a control needs a product decision and an accessible alternative before its physics.
    
    
    ## Reference files
    
    | File | Read when |
    | --- | --- |
    | [references/discovery-workflow.md](references/discovery-workflow.md) | Finding worthwhile opportunities for motion in an existing interface |
    | [references/decision-framework.md](references/decision-framework.md) | Default: deciding whether/why to animate, picking easing character; also the seam list for a Discovery sweep |
    | [references/spring-animations.md](references/spring-animations.md) | Spring physics, Motion `useSpring`, configuring spring params, Apple damping/response values, asymmetric open/close character, interruption mechanics |
    | [references/component-patterns.md](references/component-patterns.md) | Buttons, popovers, tooltips, drawers, modals, toasts with animation |
    | [references/clip-path-techniques.md](references/clip-path-techniques.md) | clip-path for reveals, tabs, hold-to-delete, comparison sliders |
    | [references/gesture-drag.md](references/gesture-drag.md) | Drag, swipe-to-dismiss, momentum, pointer capture, velocity handoff, momentum projection, rotary/knob drag, detents, carousel `touch-action` |
    | [references/scroll-animations.md](references/scroll-animations.md) | Scroll-triggered reveals, scrubbed/scroll-driven animation (`animation-timeline`, `useScroll`), parallax, sticky scrollytelling, and when a scroll animation shouldn't exist |
    | [references/performance-deep-dive.md](references/performance-deep-dive.md) | Jank, CSS vs JS, WAAPI, CSS variables trap, Framer Motion caveats |
    | [references/debugging-symptoms.md](references/debugging-symptoms.md) | An animation feels off and the cause isn't named: symptom-indexed tables for sluggish, robotic, cheap, jumpy, and misfiring motion |
    | [references/svg-animation.md](references/svg-animation.md) | Animating vector art: line drawing (`stroke-dashoffset`), SVG transform-origin traps, path morphing, shakes, ambient life |
    | [references/review-format.md](references/review-format.md) | Reviewing animation code: ten standards (each with flag-on-sight triggers), Before/After/Why table, Block/Approve verdict |
    | [references/contextual-animations.md](references/contextual-animations.md) | Contextual icon swaps, word-level stagger entrances, peripheral de-emphasis, fixed-offset exits |
    | [references/transition-recipes.md](references/transition-recipes.md) | Installing a CSS transition: container morph, card resize, badge, dropdown, modal, panel, page slide, icon swap, number pop-in, odometer roll, text swap, success, avatar hover, error shake |
    | [references/measurement-guide.md](references/measurement-guide.md) | Reverse-engineer: what to measure, eye vs script, reading `metrics.json`, choosing an ROI |
    | [references/curve-fitting.md](references/curve-fitting.md) | Reverse-engineer: reading `fit_curves.py` output, spring vs bezier, judging fit error, asymmetric open/close |
    | [references/code-output.md](references/code-output.md) | Reverse-engineer: emitting code for CSS, Motion/Framer Motion, SwiftUI, React Native, UIKit |
    | [references/choreography.md](references/choreography.md) | Reverse-engineer: multi-element/multi-phase motion: staggers, blur-before-move, per-edge settling |
    | [references/live-tuning.md](references/live-tuning.md) | Dialling a curve in live when there is no reference to fit against: the DevTools bezier editor, retiming in the Animations panel, when a control-panel library earns a dependency |
    | [references/vocabulary.md](references/vocabulary.md) | Naming a motion effect the user describes vaguely ("what's it called when...") |
    | [references/interface-sfx.md](references/interface-sfx.md) | Click sounds, interface audio, UI SFX, haptic-plus-sound, or "why is the web afraid of sound" |
    
    ## 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.
    - Keep keyboard focus and repeated navigation immediate. A state transition may animate if focus and task completion do not wait for it.
    - 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.
    - Tappable controls press on `:active` at 0ms and set `touch-action: manipulation`.
    - 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. Better still where the shapes allow: let the trigger *become* the surface (see the container-morph recipe).
    - **Confirm in place, not in a corner.** An action's result belongs on the control that caused it: the button becomes "Copied", holds, and reverts. A toast in the far corner makes the user's eye leave the thing they just touched to find out whether it worked. Reserve corner toasts for results with no on-screen origin (a background job finishing, an incoming message).
    - **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 tween, see the card-resize and container-morph recipes.)
    - 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. Line drawing, path morphing, and the Motion SVG origin override live in [references/svg-animation.md](references/svg-animation.md).
    - `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. Force a reflow (`void document.body.offsetHeight`) after the flip and remove the override on the next frame, or use `next-themes` `disableTransitionOnChange`.
    
    ## Easing defaults
    
    | Element                       | Duration     | Easing                           |
    | ----------------------------- | ------------ | -------------------------------- |
    | Button press feedback         | 100-160ms    | `cubic-bezier(0.22, 1, 0.36, 1)` |
    | Tooltips, small popovers      | 125-200ms    | `ease-out` or enter curve        |
    | Dropdowns, selects            | 150-250ms    | `cubic-bezier(0.22, 1, 0.36, 1)` |
    | Modals, drawers               | 200-350ms    | `cubic-bezier(0.22, 1, 0.36, 1)` |
    | Move/slide on screen          | 200-300ms    | `cubic-bezier(0.25, 1, 0.5, 1)`  |
    | Page transitions              | 250-400ms    | enter or move curve              |
    | Hover (colour/opacity)        | 200ms        | `ease`                           |
    | Hover (transform/scale)       | 100-150ms    | enter curve                      |
    | Illustrative/marketing        | Up to 1000ms | Spring 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)` (extremely steep start; the reason its 500ms doesn't read as slow)
    - **Expo out:** `cubic-bezier(0.19, 1, 0.22, 1)` for dramatic reveals, card hovers, text reveals
    - **Press:** `cubic-bezier(0.25, 0.46, 0.45, 0.94)` for button press feedback
    - **On-screen move:** `cubic-bezier(0.645, 0.045, 0.355, 1)` for back-and-forth movement that stays on screen
    
    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](https://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](references/transition-recipes.md):
    
    | UI pattern | Recipe |
    |---|---|
    | Trigger + floating dot/count | Notification badge |
    | Trigger grows into the surface it opens | Container morph |
    | Trigger + anchored surface | Menu dropdown |
    | Centred surface on top of page | Modal dialog |
    | Panel sliding into existing container | Panel reveal |
    | List ↔ detail or wizard steps | Page side-by-side slides |
    | Element dimension changes | Card resize |
    | Text updating in place | Text state swap |
    | Two icons in same slot | Icon swap |
    | Number arriving on its own | Number pop-in |
    | Number the user is driving | Odometer digit roll |
    | Confirmation / success moment | Success celebration |
    | Hovering item in horizontal stack | Avatar group hover |
    | Form validation error | Error 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.9-0.96)` not `scale(0)` (small popovers at the low end, full dialogs at the high end: a large surface already travels far in absolute pixels), and 30-50ms staggers (total under 300ms, most important element leading). Full rules and code in [references/component-patterns.md](references/component-patterns.md) and [references/contextual-animations.md](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
    
    - Gate hover (motion and paint) behind `@media (hover: hover) and (pointer: fine)`, or touch devices replay hover on tap. Inspect the generated CSS before adding a gate; Tailwind v4 already wraps `hover:` in `@media (hover: hover)`.
    - During direct manipulation, keep the element locked to the pointer with no easing; add easing only after release.
    - Optional interface SFX: sparse, gesture-unlocked, additive confirmation only. See [references/interface-sfx.md](references/interface-sfx.md).
    
    ## 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 when one owner must combine multiple transform functions, interop with non-Motion code, or survive a busy main thread: the shorthands run on `requestAnimationFrame` and drop frames when motion coincides with navigation, data loading, or hydration; CSS/WAAPI stay smooth there.
    - Motion that janks only sometimes (on open, during navigation, while data lands) is usually a long task sharing the tick, not a costly animation. Don't start an animation and expensive work in the same tick: start the motion, let a frame land, then do the work, or defer it to `transitionend`.
    - See [references/performance-deep-dive.md](references/performance-deep-dive.md) for WAAPI, compositing layers, long tasks during animation, 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.
    - Scroll-revealing product UI, above-the-fold content, or every section of a page: scroll reveals belong to a few chosen moments on marketing surfaces, run once, and never re-animate on scroll-up (see [references/scroll-animations.md](references/scroll-animations.md)).
    - Easing or duration on scrubbed (scroll-driven) motion: scroll position is the clock, so any curve or duration makes it lag the scrollbar. `linear` and no duration is correct there, and only there.
    - Installing `framer-motion` for new work: the package is now `motion` and React imports come from `motion/react`. The old package still resolves, so a mixed codebase compiles while shipping two copies of the library.
    
    ## Workflow
    
    Copy and track:
    
    ```text
    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](references/decision-framework.md): animate? purpose? easing? speed?
    2. Pick duration from the easing defaults table above. If the value is contested or the component is hard to reach, dial it live in the DevTools bezier editor rather than guessing, then bake the result into source ([references/live-tuning.md](references/live-tuning.md)).
    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](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.
    - 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.
    - Honor `prefers-reduced-motion`: replace spatial travel with immediate state changes or restrained fades. Pause looping decorations with `animation-play-state: paused` (do not yank them with `display: none`). Keep explicit user-triggered feedback. Exercise the same task in that mode.
    
    ## Discovery workflow
    
    For "where should this animate", load `references/discovery-workflow.md` and `references/decision-framework.md`. Report opportunities supported by purpose and usage frequency. Implement a suggestion only when implementation is in scope.
    
    ## 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.
    
    Resolve every `scripts/` command below relative to the installed skill directory, not the application working directory.
    
    **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.
    
    ```text
    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.
    
    Maintenance only: when changing Discovery routing or the gate, run the scenarios in `evaluations/` as a regression rubric. They never load during a user task.
    
    ## Sources
    
    Interface SFX gating taken from Craft (gustavo-fior) and Raphael Salaja's web-sound writing. Novelty 90/10 split, one-shot intro gating, and `animation-play-state` on loops taken from Rauno Freiberg. Rejected vendoring emilkowalski/skills and gustavo-fior/craft: trigger collision with this skill. Clip-path and proportional scale already lived here.
    
    ## Related skills
    
    - `product-design`: which states exist, what an action affects, and whether it is reversible. Route here first when a gesture replaces a control, since swipe-to-delete and hold-to-confirm change what the user can do before they change how it moves.
    - `ui-design` Direction mode: visual direction, palettes, typography; settle the visual system before tuning motion.
    - `ui-design` Audit mode: page/feature-level UI quality audit. Motion craft and fixes belong here.
    - Optional external `animate-text` skill where installed: curated named text effects (typewriter, line reveal, stagger builds) with exact JSON specs.
    
    Maintenance only: `evals/evals.json` contains regression scenarios for changes to this skill; it does not load during a user task.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related