references/choreography.md
# Choreography
A single fitted curve is rarely the whole story. Polish lives in *orchestration*: what
leads, lags, and settles independently. Annotate these from the contact sheet and frame
timeline, then express them per target.
## Contents
- Patterns to look for
- Reading timing offsets
- Expressing it per target
## Patterns to look for
| Pattern | What you see in the frames | Why it reads well |
|---|---|---|
| Blur-before-move | Element/backdrop blurs or dims a few frames *before* position changes | Sets context first; the move feels grounded, not abrupt |
| Over-stretch then settle | One axis scales past 1.0, then eases back | Soft and physical, not rigid |
| Per-edge / independent settling | Bottom and side edges arrive on different frames | Mimics real material; avoids a mechanical pop |
| Staggered children | List items/icons enter one after another | Builds hierarchy; the eye follows the lead |
| Tucked origin | Top edge stays clipped under a notch/island for the first third | Anchors it to where it came from |
| Asymmetric open/close | Open slow + springy, close fast + flat | Open invites attention; close gets out of the way |
## Reading timing offsets
For each property, note the **first frame it changes** and the **frame it settles** from
`metrics.json`. Convert to ms with `frame / fps * 1000`. Gaps between properties are the
choreography:
```
opacity: starts f0 settles f6 (0 -> 200ms)
blur: starts f0 settles f8 (0 -> 267ms)
translate:starts f3 settles f14 (100ms -> 467ms) <- move lags blur by ~100ms
scaleY: starts f3 peaks f11 overshoots to 1.06 <- over-stretch
```
That table *is* the spec. The delays (blur leads, move lags 100ms, scale overshoots) carry
more feel than any single easing curve.
## Expressing it per target
- **Stagger:** Motion `transition={{ staggerChildren: 0.04 }}`; CSS `animation-delay` per
item; SwiftUI `.delay(i * 0.04)`; Reanimated `withDelay(i * 40, ...)`.
- **Lead/lag between properties:** give each its own delay/duration. CSS: comma-separate
transitions (`transform 300ms ... 100ms, filter 200ms ... 0ms`). Motion/Reanimated:
separate values with their own `delay`.
- **Over-stretch:** a keyframe past 1.0 (CSS `scaleY(1.06)` at 70%) or a spring with
`overshoot: true`; don't flatten to a monotonic ease.
- **Independent edge settling:** animate `scaleX`/`scaleY` (or `transform-origin`-anchored
edges) on separate curves, not uniform `scale`.
- **Asymmetric open/close:** two distinct transitions; enter slower/springier, exit
faster/flatter. Never reuse the open curve reversed.
references/clip-path-techniques.md
# clip-path for Animation
`clip-path` is hardware-accelerated and creates effects impossible with `opacity` and `transform` alone.
## Contents
- [The inset shape](#the-inset-shape)
- [Tab colour transitions](#tab-colour-transitions)
- [Hold-to-delete](#hold-to-delete)
- [Image reveals on scroll](#image-reveals-on-scroll)
- [Comparison sliders](#comparison-sliders)
## The inset shape
`clip-path: inset(top right bottom left)` clips a rectangle. Each value eats into the element from that side.
```css
/* Fully hidden from right */
.hidden { clip-path: inset(0 100% 0 0); }
/* Fully visible */
.visible { clip-path: inset(0 0 0 0); }
```
Transition between states:
```css
.reveal {
clip-path: inset(0 100% 0 0);
transition: clip-path 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.reveal.active {
clip-path: inset(0 0 0 0);
}
```
## Tab colour transitions
Duplicate the tab list. Style the copy as active (different background and text colour). Clip it so only the active tab shows. Animate the clip on tab change. This gives a seamless colour transition that per-tab `color` timing can't match.
```css
.tabs-active-overlay {
clip-path: inset(0 var(--clip-right) 0 var(--clip-left));
transition: clip-path 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
```
Update `--clip-left` and `--clip-right` via JS on tab change.
## Hold-to-delete
Put `clip-path: inset(0 100% 0 0)` on a coloured overlay. On `:active`, transition to `inset(0 0 0 0)` over 2s `linear`. On release, snap back with 200ms `ease-out`. Add `scale(0.97)` on the button for press feedback.
```css
.delete-overlay {
clip-path: inset(0 100% 0 0);
transition: clip-path 200ms ease-out;
}
.delete-button:active .delete-overlay {
clip-path: inset(0 0 0 0);
transition: clip-path 2s linear;
}
```
## Image reveals on scroll
Start hidden from bottom with `clip-path: inset(0 0 100% 0)`. Animate to `inset(0 0 0 0)` on viewport entry.
```tsx
"use client";
import { useRef, useEffect, useState } from "react";
export function RevealImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setVisible(true); },
{ threshold: 0.1, rootMargin: "-100px" }
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<div
ref={ref}
style={{
clipPath: visible ? "inset(0 0 0 0)" : "inset(0 0 100% 0)",
transition: "clip-path 800ms cubic-bezier(0.77, 0, 0.175, 1)",
}}
>
<img src={src} alt={alt} />
</div>
);
}
```
## Comparison sliders
Overlay two images. Clip the top with `clip-path: inset(0 50% 0 0)`. Adjust the right inset by drag position. No extra DOM, fully hardware-accelerated.
```css
.comparison-top {
clip-path: inset(0 var(--split) 0 0);
}
```
Update `--split` from pointer events on the handle.
references/code-output.md
# Code Output
Templates that turn fitted parameters into runnable code per target. Substitute numbers from `fit_curves.py`; keep movement on `transform`/`opacity` (never layout props).
## Contents
- CSS
- Motion / Framer Motion
- SwiftUI
- React Native (Reanimated)
- UIKit
- Handoff spec
- Notes
Throughout: `D` = `duration_ms`, `BEZIER` = fitted `cubic-bezier(...)`, `{k, c, m}` = fitted `stiffness, damping, mass`.
## CSS
Monotonic: use the fitted bezier:
```css
.element {
transition: transform Dms BEZIER, opacity Dms ease-out;
}
```
Overshoot/spring: a bezier can't ring, so sample the spring into `linear()`:
```css
/* generated from the spring response; more points = smoother overshoot */
.element {
transition: transform Dms linear(0, 0.42 12%, 1.08 46%, 0.98 68%, 1);
}
```
Multi-phase: each phase is a keyframe stop with its own easing:
```css
@keyframes morph {
0% { transform: translateY(-12px) scaleY(0.9); filter: blur(6px); opacity: 0; }
35% { filter: blur(0); opacity: 1; } /* blur/opacity lead the move */
70% { transform: translateY(0) scaleY(1.06); } /* over-stretch */
100% { transform: translateY(0) scaleY(1); } /* settle */
}
```
## Motion / Framer Motion
Spring (preferred when the fit shows overshoot):
```tsx
<motion.div
initial={{ y: -12, opacity: 0, filter: "blur(6px)" }}
animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
transition={{ type: "spring", stiffness: k, damping: c, mass: m }}
/>
```
Tween (monotonic):
```tsx
transition={{ duration: D / 1000, ease: [x1, y1, x2, y2] }} // fitted bezier control points
```
## SwiftUI
```swift
withAnimation(.spring(response: RESPONSE, dampingFraction: ZETA)) {
isOpen = true // drive layout/offset/scale off this state
}
// RESPONSE = 2 * .pi * sqrt(m / k); ZETA = fitted `zeta`
```
Monotonic alternative:
```swift
withAnimation(.timingCurve(x1, y1, x2, y2, duration: D / 1000.0)) { isOpen = true }
```
## React Native (Reanimated)
```ts
// spring
offset.value = withSpring(target, { stiffness: k, damping: c, mass: m });
// monotonic
offset.value = withTiming(target, {
duration: D,
easing: Easing.bezier(x1, y1, x2, y2),
});
```
## UIKit
Spring: `CASpringAnimation` carries fitted params directly:
```swift
let a = CASpringAnimation(keyPath: "transform.translation.y")
a.stiffness = k; a.damping = c; a.mass = m
a.fromValue = -12; a.toValue = 0
a.duration = a.settlingDuration // let the physics decide
layer.add(a, forKey: "morph")
```
Monotonic: `UIViewPropertyAnimator` with fitted bezier control points:
```swift
let curve = UICubicTimingParameters(controlPoint1: CGPoint(x: x1, y: y1),
controlPoint2: CGPoint(x: x2, y: y2))
let animator = UIViewPropertyAnimator(duration: D / 1000.0, timingParameters: curve)
animator.addAnimations { view.transform = .identity; view.alpha = 1 }
animator.startAnimation()
```
## Handoff spec
A self-contained artifact someone can implement without the video. Emit one per direction (open and close), filled from `fit_curves.py` + the choreography table:
```markdown
## Motion spec: <element> (open)
Duration: <duration_ms> ms · Trigger: <what starts it>
| Property | Model | Params | Easing / config | Fit err |
|----------|-------|--------|-----------------|---------|
| translate | bezier | n/a | cubic-bezier(0.32,0,0,1) | 0.012 |
| scaleY | spring | k=180 c=14 m=1 | overshoot, zeta 0.53 | 0.024 |
| opacity | bezier | n/a | ease-out, 0-220ms | 0.02 |
| blur | n/a | 8px→0 | leads move by ~100ms | n/a |
Choreography: blur+opacity lead; translate lags ~100ms; scaleY over-stretches to 1.06
then settles. Bottom/side edges settle independently.
Reference implementation (<target>):
<chosen snippet from above>
```
Pair with the original `contact_sheet.png` so the reviewer can eyeball result against source.
## Notes
- Map fitted numbers onto each API's own parameters; don't hardcode a different look than measured.
- Web targets follow the repo's motion rules (animate `transform`/`opacity` only, interruptible). Hand the spec to the `ui-animation` skill to productionize.
- Emit **two** transitions when open and close differ (see `references/curve-fitting.md` and `references/choreography.md`); a shared transition flattens the asymmetry.
references/component-patterns.md
# Component Animation Patterns
## Contents
- [Buttons](#buttons)
- [Popovers and dropdowns](#popovers-and-dropdowns)
- [Tooltips](#tooltips)
- [Drawers and panels](#drawers-and-panels)
- [Modals and dialogs](#modals-and-dialogs)
- [Toasts](#toasts)
- [Crossfade transitions](#crossfade-transitions)
- [Lists and stagger](#lists-and-stagger)
- [Hover effects](#hover-effects)
- [Step form navigation](#step-form-navigation)
- [3D transforms](#3d-transforms)
## Buttons
Add `transform: scale(0.97)` on `:active` for instant press feedback.
```css
.button {
transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.button:active {
transform: scale(0.97);
}
```
`scale(0.9)` is too aggressive: the button visibly collapses, drawing the eye to the shrinking rather than the action. Press feedback should be felt, not seen; stay in the `0.96-0.98` range.
Mask imperfect crossfade between button states with blur:
```css
.button-content.transitioning {
filter: blur(2px);
opacity: 0.7;
}
```
Blur under 20px; heavy blur is expensive, especially in Safari.
## Popovers and dropdowns
Scale in from the trigger point, not from center; the default `transform-origin: center` is wrong for popovers.
```css
/* Radix UI */
.popover {
transform-origin: var(--radix-popover-content-transform-origin);
}
/* Data attribute fallback */
.popover[data-side="top"] { transform-origin: bottom center; }
.popover[data-side="bottom"] { transform-origin: top center; }
.popover[data-side="left"] { transform-origin: center right; }
.popover[data-side="right"] { transform-origin: center left; }
```
Start at `scale(0.88)`, never `scale(0)`: nothing appears from nothing.
```css
.menu {
transform: scale(0.88);
opacity: 0;
transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.menu[data-open="true"] {
transform: scale(1);
opacity: 1;
}
```
## Tooltips
Delay first appearance (300-500ms) to prevent accidental activation. Once one tooltip is open, subsequent ones open instantly.
```css
.tooltip {
transition: transform 125ms ease-out, opacity 125ms ease-out;
transform-origin: var(--transform-origin);
}
.tooltip[data-starting-style],
.tooltip[data-ending-style] {
opacity: 0;
transform: scale(0.97);
}
.tooltip[data-instant] {
transition-duration: 0ms;
}
```
## Drawers and panels
Use the move easing curve. Percentage `translateY`/`translateX` adapts to any height.
```css
.drawer {
transform: translateY(100%);
transition: transform 240ms cubic-bezier(0.25, 1, 0.5, 1);
}
.drawer[data-open="true"] {
transform: translateY(0);
}
```
```tsx
<motion.aside
initial={{ transform: "translate3d(100%, 0, 0)" }}
animate={{ transform: "translate3d(0, 0, 0)" }}
exit={{ transform: "translate3d(100%, 0, 0)" }}
transition={{ duration: 0.24, ease: [0.25, 1, 0.5, 1] }}
/>
```
## Modals and dialogs
**Exception: modals keep `transform-origin: center`.** They're app-level state, not anchored to a trigger.
Use `@starting-style` for entry animations without JavaScript:
```css
.modal {
opacity: 1;
transform: scale(1);
transition: opacity 250ms cubic-bezier(0.22, 1, 0.36, 1),
transform 250ms cubic-bezier(0.22, 1, 0.36, 1);
@starting-style {
opacity: 0;
transform: scale(0.95);
}
}
```
Fall back to the `data-mounted` attribute pattern when `@starting-style` browser support is insufficient.
## Toasts
Enter and exit from the same direction for spatial consistency (makes swipe-to-dismiss intuitive).
```css
.toast {
transform: translate3d(0, 6px, 0);
opacity: 0;
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.toast[data-open="true"] {
transform: translate3d(0, 0, 0);
opacity: 1;
}
```
Use CSS transitions (not keyframes) for toasts: added rapidly, and keyframes restart on interruption while transitions retarget smoothly.
## Crossfade transitions
When the container is small or outgoing/incoming content are structurally similar, a full directional slide adds too much visual weight; use a crossfade with a subtle directional hint instead.
```css
.view-enter {
opacity: 0;
transform: translateY(8px);
filter: blur(4px);
transition: opacity 150ms ease-out, transform 150ms ease-out, filter 150ms ease-out;
}
.view-enter-active {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
```
Crossfade candidates: nav content swaps, tab panels with similar structure, small card state changes. The 8px shift signals "the view changed" without the visual weight of content traveling across the screen.
## Lists and stagger
Keep stagger delays short (30-50ms per item); total under 300ms.
```css
.item {
opacity: 0;
transform: translateY(8px);
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.list[data-open="true"] .item {
opacity: 1;
transform: translateY(0);
}
.list[data-open="true"] .item:nth-child(2) { transition-delay: 50ms; }
.list[data-open="true"] .item:nth-child(3) { transition-delay: 100ms; }
.list[data-open="true"] .item:nth-child(4) { transition-delay: 150ms; }
```
```tsx
const listVariants = {
show: { transition: { staggerChildren: 0.05 } },
};
```
Never block interaction while stagger animations are playing.
When removing items, use `AnimatePresence mode="popLayout"` so the exiting element is pulled out of document flow immediately and siblings start reflowing in parallel with the exit. The default mode waits for exit to finish before siblings move, causing sequential rather than parallel motion.
```tsx
<AnimatePresence mode="popLayout">
{items.map((item) => (
<motion.div
key={item.id}
layout
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
/>
))}
</AnimatePresence>
```
## Hover effects
Gate hover animations behind a media query to avoid false positives on touch.
```css
@media (hover: hover) and (pointer: fine) {
.link {
transition: color 200ms ease, opacity 200ms ease;
}
.link:hover {
opacity: 0.8;
}
}
```
Fix hover flicker: apply hover on the parent, animate the child. `translateY` on the target itself moves the element out from under the cursor at the bottom edge, ending the hover and looping infinitely.
```css
.box:hover .box-inner {
transform: translateY(-20%);
}
.box-inner {
transition: transform 200ms ease;
}
```
For scale-based hover, use `scale(1.01)` to `scale(1.02)`; `scale(1.05)` is visibly inflated. Hover transitions should be 100-150ms; 300ms feels laggy because the user's eye is already on the element.
```css
@media (hover: hover) and (pointer: fine) {
.card {
transition: transform 120ms cubic-bezier(0.22, 1, 0.36, 1);
}
.card:hover {
transform: scale(1.015);
}
}
```
## Step form navigation
Forward steps slide content left (like reading); backward steps slide content right (like undoing). Animating both directions the same way breaks the user's mental model of forward vs backward progress.
```tsx
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 100 : -100,
opacity: 0,
}),
center: { x: 0, opacity: 1 },
exit: (direction: number) => ({
x: direction > 0 ? -100 : 100,
opacity: 0,
}),
};
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={step}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
/>
</AnimatePresence>
```
## 3D transforms
For depth effects (card flips, coin spins, orbits), use `rotateX()`/`rotateY()` with `transform-style: preserve-3d` on the wrapper: stays on the GPU, needs no JavaScript. Reserve it for illustrative or delight moments, not high-frequency UI.
```css
.flip {
transform-style: preserve-3d;
transition: transform 400ms cubic-bezier(0.22, 1, 0.36, 1);
}
.flip[data-flipped="true"] {
transform: rotateY(180deg);
}
.flip .front,
.flip .back {
backface-visibility: hidden;
}
.flip .back {
transform: rotateY(180deg);
}
```
Set `perspective` on the parent (e.g. `perspective: 1000px`) to control depth intensity; smaller values exaggerate the effect. As with SVG, set `transform-box: fill-box; transform-origin: center` if the rotation pivots around the wrong point.
references/contextual-animations.md
# Contextual Animations
Patterns for icon swaps, word-level stagger entrances, and subtle exits.
## Contents
- [Contextual icon swaps](#contextual-icon-swaps)
- [Word-level stagger entrances](#word-level-stagger-entrances)
- [Subtle exit animations](#subtle-exit-animations)
---
## Contextual icon swaps
For contextual state swaps (copy → check, play → pause, send → sent), animate `opacity`, `scale`, and `blur` together: the swap feels responsive, not instant, and blur hides the crossfade seam between outgoing and incoming icons.
**Motion (preferred, supports springs):**
```tsx
import { AnimatePresence, motion } from "motion/react"
<button onClick={handleCopy}>
<AnimatePresence mode="wait" initial={false}>
{isCopied ? (
<motion.span
key="check"
initial={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.2, bounce: 0 }}
>
<CheckIcon />
</motion.span>
) : (
<motion.span
key="copy"
initial={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.2, bounce: 0 }}
>
<CopyIcon />
</motion.span>
)}
</AnimatePresence>
</button>
```
**CSS only:**
```css
.icon {
transition:
opacity 150ms ease,
scale 150ms ease,
filter 150ms ease;
}
.icon[data-hidden] {
opacity: 0;
scale: 0.8;
filter: blur(4px);
pointer-events: none;
}
```
`mode="wait"` makes the exit finish before the enter starts, so both icons are never visible at once.
---
## Word-level stagger entrances
For hero text or page-header entrances, split content into sections (or words) and stagger each. Combine `opacity + translateY + blur`; any property alone looks flat, mechanical, or cheap.
**Two levels of stagger:**
| Level | Delay | Use for |
|-------|-------|---------|
| Section-level | 100ms per section | Title block, description block, button group |
| Word-level | 80ms per word | Hero headline only |
**CSS pattern:**
```css
@keyframes enter {
from {
transform: translateY(8px);
filter: blur(5px);
opacity: 0;
}
}
.animate-enter {
animation: enter 800ms cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
animation-delay: calc(var(--delay, 0ms) * var(--stagger, 0));
}
/* Section level: 100ms gaps */
.animate-enter-section {
--delay: 100ms;
}
/* Word level: 80ms gaps */
.animate-enter-word {
--delay: 80ms;
}
```
**Section-level JSX:**
```tsx
<div className="animate-enter animate-enter-section" style={{ "--stagger": 1 }}>
<Title />
</div>
<div className="animate-enter animate-enter-section" style={{ "--stagger": 2 }}>
<Description />
</div>
<div className="animate-enter animate-enter-section" style={{ "--stagger": 3 }}>
<Buttons />
</div>
```
**Word-level JSX:**
```tsx
{"Track expenses, build habits".split(" ").map((word, i) => (
<span
key={word}
className="animate-enter animate-enter-word inline-block"
style={{ "--stagger": i + 1 }}
>
{word}
</span>
))}
```
These differ from the general-purpose 30-50ms item stagger in `component-patterns.md`: use 30-50ms for lists, 80-100ms for page-level entrances where each chunk carries narrative weight.
---
## Subtle exit animations
Exits should be directional (signal where content goes) but quieter than enters. Use a small fixed offset, not the computed element height.
**Full exit (too much movement for overlays):**
```tsx
<motion.div
exit={{
opacity: 0,
y: "calc(-100% - 4px)", // the full height, plus gap
filter: "blur(4px)",
}}
transition={{ type: "spring", duration: 0.45, bounce: 0 }}
/>
```
**Subtle exit (recommended):**
```tsx
<motion.div
initial={{ opacity: 0, y: "calc(-100% - 4px)", filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{
opacity: 0,
y: "-12px", // fixed value, regardless of element height
filter: "blur(4px)",
}}
transition={{ type: "spring", duration: 0.45, bounce: 0 }}
/>
```
Keep `-12px` fixed, never computed from dimensions: the exit conveys direction, not the full path. Enter uses full distance to build presence; exit uses a short fixed distance to release attention quietly.
Spring: `{ type: "spring", duration: 0.45, bounce: 0 }`; zero bounce for a clean exit.
references/curve-fitting.md
# Curve Fitting
Read `fit_curves.py` output, choose spring vs cubic-bezier, judge fit quality, handle
asymmetric open/close curves.
## Contents
- Reading the output
- Spring vs bezier
- Judging fit error
- Asymmetric open/close
- Converting spring params across APIs
## Reading the output
`fit_curves.py` prints one block per property that moved:
```json
{
"duration_ms": 520,
"properties": {
"translate": {
"spring": { "stiffness": 210.4, "damping": 28.0, "mass": 1.0,
"zeta": 0.97, "overshoot": false, "error": 0.018 },
"bezier": { "cubic_bezier": [0.32, 0.0, 0.0, 1.0],
"css": "cubic-bezier(0.32, 0.0, 0.0, 1.0)", "error": 0.012 },
"recommended": "bezier"
},
"scaleY": {
"spring": { "stiffness": 180.0, "damping": 14.2, "mass": 1.0,
"zeta": 0.53, "overshoot": true, "error": 0.024 },
"bezier": { "cubic_bezier": [0.18, 1.42, 0.30, 1.0],
"css": "cubic-bezier(0.18, 1.42, 0.30, 1.0)", "error": 0.061 },
"recommended": "spring"
}
}
}
```
Here `scaleY` overshoots (`zeta` 0.53) while `scaleX` barely moves: the vertical
over-stretch-then-settle that makes a morph feel fluid. Fitting the axes separately surfaces it.
- `recommended` is just whichever model had the lower `error`. Sanity-check against what you
saw: a clear overshoot should pick spring or a bezier with `y1`/`y2` > 1.
- `overshoot: true` (`zeta` < 1) means the motion rings past its target and settles back, the
elastic, springy feel. `zeta` ≈ 1 is a crisp ease with no bounce; `zeta` > 1 is slow and heavy.
## Spring vs bezier
| Pick spring when | Pick bezier when |
|---|---|
| Motion overshoots / bounces / settles | Motion is monotonic (no overshoot) |
| Target API is spring-native (Motion, SwiftUI, Reanimated) | Target is CSS `transition`/`@keyframes` |
| Duration should emerge from physics | Duration is fixed and known |
| It must stay interruptible mid-flight | One-shot, non-interruptible play |
You can ship a spring *as* a bezier (the fit gives both), but a true overshoot needs a bezier
whose `y1`/`y2` exceed 1, or CSS `linear()` with sampled points; a spring expressed as a plain
ease-out loses the bounce. The fitter caps `y1`/`y2` at 1.5, so a bigger bounce fits better as
a spring (or a sampled `linear()`), never as a bezier.
## Judging fit error
`error` is normalized RMS against 0->1 progress, so it's comparable across properties.
| error | Reading |
|---|---|
| < 0.03 | Tight fit: use the parameters directly |
| 0.03-0.08 | Decent: eyeball the recommended curve against the contact sheet |
| > 0.08 | Suspect: usually multi-phase motion (see below), wrong element, or too few frames |
**High error on BOTH models almost always means multi-phase motion** (blur-in, then move, then
over-stretch, then settle). One curve can't fit that: split the timeline at the phase boundary
(read the frame index off the contact sheet), fit each segment by slicing `metrics.json`, then
compose them as a keyframe sequence with per-segment easing.
If error is high because the property barely moved, it won't appear at all: `progress()` drops
series with under ~1% range so you don't fit curves to noise.
Two more error inflators to rule out before splitting phases:
- **Wrong `--fps`**: `fit_curves.py` defaults to 30; if extraction used another rate, every
`duration_ms` and stiffness is rescaled. Pass the extraction fps.
- **Duplicated frames**: runs of identical rows in `metrics.json` (over-sampled or
variable-frame-rate source) plateau the progress curve and raise error on both models.
Re-extract at the source rate or re-record.
## Asymmetric open/close
Open and close are almost never mirror images: fit each direction as its own clip and report two curves, never one curve reused reversed. Full treatment (why, and expressing it per target) in `references/choreography.md`.
## Converting spring params across APIs
The fit fixes `mass = 1`. From `stiffness` (k), `damping` (c), `mass` (m):
- **Motion / Framer Motion**: pass `stiffness`, `damping`, `mass` into
`transition: { type: "spring", stiffness, damping, mass }`.
- **SwiftUI**: `Spring(mass:stiffness:damping:)`, or approximate with
`.spring(response:, dampingFraction:)` where `response = 2π·√(m/k)` and
`dampingFraction = c / (2·√(k·m))` (that's `zeta`).
- **Reanimated**: `withSpring(to, { stiffness, damping, mass })`.
- **CSS**: no native spring. Use the fitted `bezier.css`, or generate a `linear()` easing by
sampling the spring response (more faithful for overshoot). Read `references/code-output.md`.
references/decision-framework.md
# Animation Decision Framework
## Contents
- [1. Should this animate at all?](#1-should-this-animate-at-all)
- [2. What is the purpose?](#2-what-is-the-purpose)
- [3. What easing should it use?](#3-what-easing-should-it-use)
- [4. How fast should it be?](#4-how-fast-should-it-be)
Answer these four questions in order before writing animation code.
## 1. Should this animate at all?
**How often will users see this animation?**
| Frequency | Examples | Decision |
|---|---|---|
| 100+ times/day | Keyboard shortcuts, command palette toggle | No animation. Ever. |
| Tens of times/day | Hover effects, list navigation | Remove or drastically reduce |
| Occasional | Modals, drawers, toasts | Standard animation |
| Rare / first-time | Onboarding, feedback forms, celebrations | Can add delight |
Never animate keyboard-initiated actions; they repeat hundreds of times daily and animation makes them feel slow and disconnected.
## 2. What is the purpose?
Answer "why does this animate?" before writing code.
| Purpose | Description | Example |
|---|---|---|
| **Feedback** | Confirms user action was received | Button scale on press, toggle state |
| **Orientation** | Shows spatial relationship | Drawer slides from edge, menu scales from trigger |
| **Continuity** | Preserves context across state changes | Page transitions, layout shifts |
| **Delight** | Adds personality (use sparingly) | Stagger reveals, spring overshoot |
If the purpose is just "it looks cool" and users see it often, don't animate.
## 3. What easing should it use?
Follow this decision tree:
- **Entering the viewport?** → enter curve: `cubic-bezier(0.22, 1, 0.36, 1)`
- **Exiting the viewport?** → same curve, shorter duration
- **Moving/sliding on screen?** → move curve: `cubic-bezier(0.25, 1, 0.5, 1)`
- **Simple hover (color/opacity)?** → `200ms ease`
- **Needs physics feel?** → spring
- **Direct manipulation (drag)?** → no easing, follow the pointer
- **Constant motion (marquee, spinner)?** → `linear`
Avoid `ease-in` for UI; it starts slow and feels sluggish. Built-in `ease-out`/`ease` have gentle acceleration that reads soft rather than decisive. Custom curves like `cubic-bezier(0.22, 1, 0.36, 1)` accelerate steeply (the element covers most of its distance in the first third), so the same 200ms feels significantly faster.
**Easing resources:** [easing.dev](https://easing.dev/) and [easings.co](https://easings.co/) for stronger custom variants.
For a full named cubic-bezier catalogue (ease-out-quad through ease-out-expo, symmetric variants), see [easing.dev](https://easing.dev/); SKILL.md ships the actionable defaults. Use weaker curves (quad, cubic) for small or frequent elements; stronger curves (quint, expo) for large or rare transitions.
### Asymmetric vs symmetric curves
Symmetric ease-in-out starts slow: a noticeable lag between the user's action and the element beginning to move. For interactive elements (drawers, panels, menus), use asymmetric curves, steep at the start and settling slowly, to preserve responsiveness while the slow deceleration adds quality.
Duration and easing are inseparable: a steep curve affords a longer duration because the movement is front-loaded. Vaul's drawer uses 500ms with `cubic-bezier(0.32, 0.72, 0, 1)` but doesn't feel slow, covering most of its distance in the first 200ms.
## 4. How fast should it be?
Pick duration from the easing defaults table in SKILL.md. Keep routine UI under 300ms; scale with distance: a full-screen menu can exceed 300ms, a 6px tooltip shift under 150ms.
### Perceived performance
Animation speed changes perceived performance:
- Fast-spinning spinner makes loading feel faster (same time, different perception)
- `ease-out` at 200ms _feels_ faster than `ease-in` at 200ms: user sees immediate movement
- Instant tooltips after the first opens (skip delay and animation) make the toolbar feel faster
### Asymmetric timing
Enter can be slightly slower than exit. Hold-to-delete: 2s linear on press, 200ms ease-out on release.
```css
/* Release: fast */
.overlay {
transition: clip-path 200ms ease-out;
}
/* Press: slow and deliberate */
.button:active .overlay {
transition: clip-path 2s linear;
}
```
### Instant enter, animated exit (productivity tools)
Restates SKILL.md's asymmetric-timing core rule for high-frequency ephemeral UI: enter instantly (0ms), exit with a brief fade (100-150ms).
```css
/* Hover highlight: instant appear, soft dismiss */
.highlight {
transition: opacity 0.15s ease-out;
opacity: 0;
}
.item:hover .highlight {
transition-duration: 0s;
opacity: 1;
}
```
Once the element should animate, match the UI pattern to a recipe via the "Transition decision rules" table in SKILL.md.
references/gesture-drag.md
# Gesture and Drag Animations
Drag, swipe, and gesture patterns where the user directly manipulates elements.
## Contents
- [Momentum-based dismissal](#momentum-based-dismissal)
- [Velocity handoff](#velocity-handoff)
- [Momentum projection](#momentum-projection)
- [Boundary damping](#boundary-damping)
- [Pointer capture](#pointer-capture)
- [Multi-touch protection](#multi-touch-protection)
- [Friction vs hard stops](#friction-vs-hard-stops)
- [Swipe-to-dismiss pattern](#swipe-to-dismiss-pattern)
## Momentum-based dismissal
Don't require dragging past a distance threshold; compute velocity at release so a quick flick dismisses.
```ts
function onPointerUp(e: PointerEvent) {
const timeTaken = Date.now() - dragStartTime;
const velocity = Math.abs(swipeAmount) / timeTaken;
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
dismiss();
} else {
snapBack();
}
}
```
Default threshold: velocity > 0.11. Combine with a minimum distance (e.g. 20px) to prevent accidental dismissals.
## Velocity handoff
When a gesture ends, the animation must continue at the finger's exact velocity so there is no visible seam between dragging and animating. This is the detail that most separates "fluid" from "fine". Pass the pointer's release velocity as the spring's initial velocity.
Motion and Framer Motion take absolute px/s velocity directly via the `velocity` option, so hand them the raw release velocity:
```ts
// releaseVelocity in px/s, measured over the last few pointermove events
animate(el, { y: target }, { type: "spring", velocity: releaseVelocity, bounce: 0, duration: 0.4 });
```
Some spring APIs want relative velocity: normalize by the remaining distance to the target.
```ts
const relativeVelocity = gestureVelocity / (targetValue - currentValue);
// element at y=50, target y=150 (100px to go), finger at 50px/s -> 50 / 100 = 0.5
```
To have velocity ready at release, track a short position and timestamp history (last few `pointermove` events), not just the current point.
## Momentum projection
Don't snap to the nearest boundary from the release point. Use velocity to project where the gesture is heading, then snap to the target nearest that projected point. This is what makes a flick feel like it throws the element, exactly like scroll deceleration. Good bottom sheets and carousels (Vaul, Embla) work this way.
```ts
// decelerationRate ~ 0.998 for a normal scroll feel; 0.99 for snappier
function project(initialVelocity: number, decelerationRate = 0.998): number {
return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate);
}
const projectedEndpoint = currentPosition + project(releaseVelocity);
const target = nearestSnapPoint(projectedEndpoint); // choose target from the projection
animateSpringTo(target, { velocity: releaseVelocity }); // then hand off velocity (previous section)
```
Use this exponential-decay form, not the physics-textbook `v^2 / (2 * decel)`; the decay form is what Apple ships in the *Designing Fluid Interfaces* sample code.
## Boundary damping
Past the natural boundary (e.g. pulling a drawer up when already at top), apply damping: the more they drag, the less it moves.
```ts
function applyDamping(offset: number, max: number): number {
return max * (1 - Math.exp(-offset / max));
}
// Usage: as offset grows, movement diminishes
const dampedOffset = applyDamping(rawOffset, 200);
```
Apple's canonical rubber-band function (from *Designing Fluid Interfaces*) is a good drop-in alternative, tuned to feel like iOS overscroll:
```ts
// the further past the bound, the less the element follows
function rubberband(overshoot: number, dimension: number, constant = 0.55): number {
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
}
```
Real things slow before stopping; friction beats hard stops.
## Pointer capture
On drag start, capture all pointer events so the drag continues even if the pointer leaves the element.
```ts
function onPointerDown(e: PointerEvent) {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
isDragging = true;
}
function onPointerUp(e: PointerEvent) {
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
isDragging = false;
}
```
Always use `setPointerCapture`; without it, fast swipes escape the element and the drag breaks.
## Multi-touch protection
Ignore extra touch points after the drag begins; without this, switching fingers mid-drag makes the element jump.
```ts
let activeTouchId: number | null = null;
function onPointerDown(e: PointerEvent) {
if (activeTouchId !== null) return; // Ignore additional touches
activeTouchId = e.pointerId;
// Start drag...
}
function onPointerUp(e: PointerEvent) {
if (e.pointerId !== activeTouchId) return;
activeTouchId = null;
// End drag...
}
```
## Friction vs hard stops
Allow drag past a boundary, with increasing friction:
```ts
function applyFriction(delta: number, isAtBoundary: boolean): number {
if (!isAtBoundary) return delta;
return delta * 0.3; // 30% of movement at boundary
}
```
Hard stops feel broken; users expect physics. Apply friction for scroll containers, sliders, and drawers.
## Swipe-to-dismiss pattern
Combine velocity, distance, and direction for a complete swipe gesture:
```ts
function handleSwipeEnd(direction: "left" | "right", distance: number, velocity: number) {
const shouldDismiss = distance > THRESHOLD || velocity > 0.11;
if (shouldDismiss) {
// Animate out in swipe direction, handing off the release velocity (see Velocity handoff)
animateOut(direction, velocity);
} else {
// Spring back to origin
springBack();
}
}
```
The exit should continue in the swipe direction with momentum; snapping elsewhere feels wrong. Feed `velocity` into the exit spring's `velocity` option so drag and animation share no seam.
references/measurement-guide.md
# Measurement Guide
What to measure in a recording, when to trust eyes vs scripts, and how to read
`track_motion.py` output.
## Contents
- Property checklist
- By eye vs scripted
- Reading metrics.json
- Choosing an ROI / bbox
- Pixel-tracking pitfalls
## Property checklist
Walk every animation against this list. Most "magic" hides in properties people forget:
opacity and blur lead the move, not position.
| Property | What it looks like | Field in metrics.json |
|---|---|---|
| Translate | Element changes position | `cx`, `cy` |
| Scale (per axis) | Box grows/shrinks, may over-stretch one axis | `width` → `scaleX`, `height` → `scaleY` |
| Opacity | Fades in/out, or a backdrop dims | `opacity` |
| Blur | Soft on entry, sharpens as it settles (or backdrop blur) | `blur` |
| Corner radius | Pill morphs to card; corners round/sharpen | `radius` |
| Shadow / elevation | Shadow grows as the element lifts | (measure by eye) |
| Color / fill | Background or tint shifts | (measure by eye) |
Anisotropic scale matters: a "fluid" morph usually stretches `height` ahead of `width` (or
vice versa), settling each independently. `fit_curves.py` fits `scaleX` (width) and `scaleY`
(height) separately for this reason. Compare them; don't assume uniform scale.
## By eye vs scripted
Reach for the contact sheet first; escalate to scripts only where precision pays off.
| Decide by eye | Measure with scripts |
|---|---|
| Which element moves, and the effect list | Exact per-frame position / size |
| Rough phase order (blur-in, then move, then settle) | Whether motion overshoots and by how much |
| Direction and origin of the motion | Spring vs bezier and its parameters |
| Whether open and close differ | Precise duration and per-property timing offsets |
A plain fade or linear slide needs no tracking: read the timing off the contact sheet and code
it. Spend the OpenCV/scipy budget on elastic, springy, or multi-property motion where
eyeballing is unreliable.
## Reading metrics.json
`track_motion.py` writes one record per frame:
```json
{ "frame": 7, "file": "frame_0008.png",
"x": 38, "y": 96, "width": 318, "height": 360,
"cx": 197.0, "cy": 276.0, "opacity": 0.82, "blur": 0.31, "radius": 0.44 }
```
- Frames before entry or after exit show `{"present": false}`: the element is absent, not an
error. The summary line reports how many frames were skipped.
- All values are **pixel-derived proxies**, not ground truth: `opacity` is box fill-ratio,
`blur` is inverse Laplacian sharpness, `radius` is empty-corner fraction. Treat them as
*shapes over time* to fit against, not absolute CSS values.
- Watch the **trend**, not the absolute number. A `blur` falling 0.6 -> 0.1 over the first
third tells you blur leads the move, whatever the exact figures.
## Choosing an ROI / bbox
Auto-detection diffs each frame against frame 1 and tracks the changed region. Works when one
element animates over a still backdrop. `--bbox X,Y,W,H` restricts that frame-diff detection
to the region; the element is still tracked *inside* it, so position, scale, and opacity stay
meaningful. Pass it when:
- Multiple things move and you want one (measure each separately, one bbox each).
- The backdrop also animates (e.g. a dimming overlay), polluting the diff.
- The element starts off-screen: pick the bbox where it ends up.
Use `--threshold` to loosen/tighten detection and `--invert` when the element is the *still*
part and the background moves. Read bbox coordinates straight off a frame.
## Pixel-tracking pitfalls
- **Dynamic Island / notch occlusion**: an element tucked under the island reads as a smaller
box for the first frames. Expect clipped `height` early; trust later frames for true size.
- **Anti-aliasing & motion blur**: fast frames smear edges, inflating the `blur` proxy and
softening the box. Sample at a higher `--fps` for very fast motion.
- **Drop shadows**: a soft shadow extends the box beyond the element. If `width` looks too
large, tighten `--threshold` so faint shadow pixels fall below it.
- **Compression artifacts**: heavy compression adds diff noise; `MIN_AREA_FRAC` filters specks,
but a high-bitrate recording always tracks cleaner.
references/performance-deep-dive.md
# Performance Deep Dive
Advanced performance guidance beyond the quick rules in SKILL.md.
## Contents
- [CSS vs JS animations](#css-vs-js-animations)
- [Web Animations API (WAAPI)](#web-animations-api-waapi)
- [CSS variables inheritance trap](#css-variables-inheritance-trap)
- [Motion transform ownership](#motion-transform-ownership)
- [Pause looping animations off-screen](#pause-looping-animations-off-screen)
- [Compositing layers and will-change](#compositing-layers-and-will-change)
- [Fix shaky 1px shifts](#fix-shaky-1px-shifts)
## CSS vs JS animations
| Approach | Driver | Interruptible | Best for |
|---|---|---|---|
| CSS transitions | Browser/compositor for transform/opacity | Yes (retargets) | Predetermined state changes |
| CSS keyframes | Browser/compositor when properties allow it | No (restarts from zero) | Looping, predetermined sequences |
| WAAPI (`el.animate()`) | Browser animation engine | Yes (cancel/reverse) | Dynamic values with imperative control |
| Motion values (`x`, `y`, `style`) | Motion DOM renderer, no React re-renders | Yes | React gestures, drag, coordinated UI |
| JS (`requestAnimationFrame`) | Main thread | Yes (manual) | Complex choreography, physics |
**Rule: CSS transitions > WAAPI > CSS keyframes > JS.** Under load (page navigation, heavy rendering), CSS stays smooth while JS drops frames.
## Web Animations API (WAAPI)
JavaScript control with CSS performance. Hardware-accelerated, interruptible, promise-based.
```ts
const animation = element.animate(
[
{ transform: "translateY(100%)", opacity: 0 },
{ transform: "translateY(0)", opacity: 1 },
],
{
duration: 300,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
fill: "forwards",
}
);
// Cancel or reverse at any time
animation.reverse();
await animation.finished;
```
## CSS variables inheritance trap
A CSS variable change on a parent recalculates styles for **all children**. In a drawer with many items, updating `--swipe-amount` on the container forces expensive recalc on every one.
```ts
// Bad: triggers recalc on all children
element.style.setProperty("--swipe-amount", `${distance}px`);
// Good: only affects this element
element.style.transform = `translateY(${distance}px)`;
```
Exception: `@property` with `inherits: false` avoids the cascade, but has limited browser support.
## Motion transform ownership
Motion's `x`/`y` are first-class APIs for single-axis movement and drag: they update without React re-renders and are the default for gesture-heavy components.
```tsx
const x = useMotionValue(0);
// Idiomatic Motion API for drag and axis movement
<motion.div drag="x" style={{ x }} />
// Use one handwritten transform string when you need to author
// multiple transform functions together or interop with non-Motion code
<motion.div animate={{ transform: "translateX(100px) rotate(4deg)" }} />
```
Don't mix Motion `x`/`y` props with a handwritten `transform` string on one element; pick one transform owner.
## Pause looping animations off-screen
Looping animations consume GPU resources even when not visible.
```ts
"use client";
import { useEffect, useRef } from "react";
export function usePauseOffscreen<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => {
el.style.animationPlayState = entry.isIntersecting ? "running" : "paused";
});
io.observe(el);
return () => io.disconnect();
}, []);
return ref;
}
```
## Compositing layers and will-change
`will-change` creates a new compositor layer, at a memory cost.
- Only promote during animation, remove after
- Only for `transform` and `opacity`
- Too many layers is worse than no promotion
```css
.animating { will-change: transform, opacity; }
```
Toggle the class on animation start, remove on `transitionend` or `animationend`.
## Fix shaky 1px shifts
Elements can shift 1px at animation start/end from GPU/CPU handoff. Apply `will-change: transform` during the animation (not permanently) to keep compositing on the GPU throughout.
references/review-format.md
# Animation Review Format
## Contents
- [Operating posture](#operating-posture)
- [Ten non-negotiable standards](#ten-non-negotiable-standards)
- [Remedial preference hierarchy](#remedial-preference-hierarchy)
- [Before/After/Why table](#beforeafterwhy-table)
- [Review checklist](#review-checklist)
- [Verdict output](#verdict-output)
## Operating posture
Senior motion reviewer with a brutal eye for craft. Bias toward motion that feels right, not motion that merely runs. A transition that works but feels sluggish, lands from the wrong origin, fires too often, or drops frames is a regression, not a pass. Default to flagging; approval is earned, not assumed.
## Ten non-negotiable standards
Measure every animation in the diff against these; a violation is a finding. For exact values (curves, durations, spring config), cite the easing/duration tables in `SKILL.md` rather than approximating. Each standard ends with a **Flag on sight** clause: hard findings to catch without deliberation.
1. **Justified motion.** Every animation answers "why animate this?": feedback, orientation, continuity, state, or deliberate delight. "Looks cool" on a frequently-seen element is a block.
2. **Frequency-appropriate.** Keyboard-initiated and 100+/day actions get no animation; tens/day gets reduced motion; occasional gets standard; rare or first-time can carry delight. Flag on sight: animation on a keyboard shortcut, command-palette toggle, or 100+/day action.
3. **Responsive easing.** Entering/exiting elements use `ease-out` or a strong custom curve; built-in CSS easings are too weak for deliberate animation. Flag on sight: `ease-in` on any UI interaction, or weak built-in easing on a deliberate animation (it delays the moment the user watches most).
4. **Sub-300ms UI.** UI animations stay under 300ms; scale duration with distance traveled. Flag on sight: UI duration > 300ms with no stated reason.
5. **Origin and physical correctness.** Popovers, dropdowns, and tooltips scale from their trigger (`transform-origin`), not center; modals stay centered. Flag on sight: `transform-origin: center` on a trigger-anchored popover/dropdown/tooltip, or `scale(0)`/pure-fade entrances with no initial transform (start at `scale(0.85-0.97)` plus opacity).
6. **Interruptibility.** Rapidly-triggered or gesture-driven motion (toasts, toggles, drags) must retarget from its current state; prefer CSS transitions or springs over keyframes, which restart from zero. Flag on sight: keyframes on toasts, toggles, or anything added/triggered rapidly.
7. **GPU-only properties.** Animate `transform` and `opacity` only. Flag on sight: animating `width`/`height`/`margin`/`padding`/`top`/`left`; `transition: all` (unbounded property animation); Framer Motion `x`/`y`/`scale` props on motion that runs while the page is busy; updating a CSS variable on a parent to drive a child transform (style recalc storm).
8. **Accessibility.** `prefers-reduced-motion` is honored (gentler, not zero: keep opacity/color, drop movement); hover animations gated behind `@media (hover: hover) and (pointer: fine)`. Flag on sight: missing reduced-motion handling on movement, or ungated `:hover` motion.
9. **Asymmetric enter/exit.** Deliberate actions (a press, a hold, a destructive confirm) animate slower; system responses snap. Flag on sight: symmetric enter/exit timing on a press-and-release or hold interaction.
10. **Cohesion.** Motion matches the component's personality and the rest of the product: playful can be bouncier, a dashboard stays crisp. When unsure whether motion feels right, the strongest move is often to delete it. Flag on sight: mismatched personality, a jarring crossfade where a subtle blur would bridge two states, or an everything-at-once entrance where a 30-50ms stagger belongs.
## Remedial preference hierarchy
Prefer earlier moves over later ones:
1. **Delete the animation** (high-frequency, no purpose, or keyboard-triggered).
2. **Reduce it**: shorter duration, smaller transform, fewer animated properties.
3. **Fix the easing**: swap `ease-in` to `ease-out` or a strong custom curve.
4. **Fix the origin and physicality**: correct `transform-origin`; replace `scale(0)` with `scale(0.95)` plus opacity.
5. **Make it interruptible**: keyframes to transitions, or a spring for gesture-driven motion.
6. **Move it to the GPU**: layout props to `transform`/`opacity`; shorthand to a full `transform` string; WAAPI for programmatic CSS.
7. **Asymmetric timing**: slow the deliberate phase, snap the response.
8. **Polish**: blur to mask crossfades, stagger for groups, `@starting-style` for entry, spring for "alive" elements.
9. **Accessibility and cohesion**: add reduced-motion and hover gating; tune to match the component's personality.
## Before/After/Why table
Required first part of every review. Markdown table, one row per issue; never a "Before:/After:" list on separate lines.
| Before | After | Why |
|---|---|---|
| `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; `all` animates unintended properties off-GPU |
| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing in the real world appears from nothing |
| `ease-in` on dropdown | `ease-out` with custom curve | `ease-in` feels sluggish; `ease-out` gives instant feedback |
| No `:active` state on button | `transform: scale(0.97)` on `:active` | Buttons must feel responsive to press |
| `transform-origin: center` on popover | `transform-origin: var(--radix-popover-content-transform-origin)` | Popovers scale from trigger (modals stay centered) |
## Review checklist
Rows add recipe-specific signal beyond the ten standards; for the standard violations (`transition: all`, layout props, `ease-in`, `scale(0)`, hover guard, symmetric timing, keyboard action, >300ms, rapid-fire keyframes) see the Flag-on-sight clauses above.
| Issue | Fix |
|---|---|
| CSS variable drag animation | Use `transform` directly on the element |
| Missing `setPointerCapture` on drag | Add pointer capture for reliable tracking |
| Motion `x`/`y` mixed with a handwritten `transform` | Pick one transform owner |
| Hard cut between views sharing elements | Add shared-element transition; animate persistent components in place |
| Contextual overlay enters from centre | Set `transform-origin` to trigger; animate outward from source |
| Elements all appear at once | Add stagger delay (30-50ms between items) |
| Touch target under 44px on interactive element | Add `::before` pseudo-element sized to 44x44px minimum (WCAG 2.5.5) |
| Hover scale > 1.03 or hover duration > 150ms | Use `scale(1.01-1.02)` and 100-150ms transition |
| Container animates AND children stagger | Pick one entrance: animate the container OR stagger children, not both |
| Missing close-state cleanup after `setTimeout` | Add `is-closing` class, remove after transition duration |
| Missing reflow (`void el.offsetWidth`) between class changes | Force reflow before re-adding classes to restart transitions |
| Animating container instead of inner pieces | Apply transitions to child elements, not the wrapper |
| Hardcoded `stroke-dasharray` on SVG success path | Use `path.getTotalLength()` to measure the path |
| `.is-error` and `.is-shaking` merged into one class | Keep them separate: `.is-shaking` controls animation only, `.is-error` controls visual state |
## Verdict output
Required second part of every review. Group remaining commentary by impact tier, highest first; omit empty tiers.
1. **Feel-breaking regressions**: sluggish easing, comes-from-nowhere entrances, motion on high-frequency or keyboard actions.
2. **Missed simplifications**: animations to remove or drastically reduce.
3. **Performance**: non-GPU properties, dropped-frame risks, recalc storms.
4. **Interruptibility and timing**: keyframes where transitions/springs belong; symmetric timing that should be asymmetric.
5. **Origin, physicality, and cohesion**: wrong origin, mismatched personality, jarring crossfades.
6. **Accessibility**: reduced-motion and pointer/hover gating.
Close with a decision, citing `file:line`:
- **Block**: any feel-breaking regression, animation on a keyboard or high-frequency action, `scale(0)` or `ease-in` on UI, or a non-GPU animation with an easy GPU fix.
- **Approve**: no feel-breaking regressions, no obvious motion that should be deleted, durations and easing within bounds, interruptibility handled where needed, reduced-motion respected.
Reusable-component library DX (defaults over options, drop-in ergonomics, naming, docs site) is authoring, not review; see the `ui-design` skill.
For debugging animations (slow-motion, DevTools Animations panel, real-device testing, next-day review), see the Validation section in `SKILL.md`.
references/spring-animations.md
# Spring Animations
Springs simulate physics, so they feel more natural than duration-based animations: no fixed duration, they settle by physical parameters.
## Contents
- [When to use springs](#when-to-use-springs)
- [Spring parameters](#spring-parameters)
- [Configuration presets](#configuration-presets)
- [Apple's damping and response framing](#apples-damping-and-response-framing)
- [Interruptibility advantage](#interruptibility-advantage)
- [Spring-based mouse interactions](#spring-based-mouse-interactions)
- [Snap instead of spring](#snap-instead-of-spring)
## When to use springs
- Drag with momentum (release, let physics take over)
- Elements that feel "alive" (Apple's Dynamic Island)
- Gestures interruptible mid-animation
- Decorative mouse-tracking interactions
- Overshoot effects (playful UI)
**Don't use springs for:** simple fades, color transitions, or precise-timing UI.
## Spring parameters
| Parameter | What it controls | Typical range |
|---|---|---|
| `stiffness` | Speed of movement (higher = faster) | 100-500 |
| `damping` | Resistance (lower = more bounce) | 15-40 |
| `mass` | Weight feel (higher = slower, heavier) | 0.5-2 |
## Configuration presets
**Apple-style (recommended, easier to reason about):**
```js
{ type: "spring", duration: 0.5, bounce: 0.2 }
```
**Traditional physics (more control):**
| Preset | stiffness | damping | Use case |
|---|---|---|---|
| Snappy (Apple default) | 500 | 40 | General UI, no bounce |
| Bouncy | 300 | 20 | Playful elements, notifications |
| Gentle | 200 | 30 | Page transitions, large elements |
| Stiff | 700 | 50 | Small precise movements |
Bounce signals brand personality. Default to zero (the safe choice): a finance dashboard should never bounce; a learning app or creative tool can use subtle bounce (0.1-0.2) to feel friendlier. The question isn't "does it look better with bounce?" but "does it match the brand?"
## Apple's damping and response framing
Apple deliberately replaced the physics triplet (mass/stiffness/damping) with two designer-friendly parameters. Reason in these:
- **Damping ratio** controls overshoot. `1.0` = critically damped, no bounce, smooth settle; `< 1.0` overshoots and oscillates; lower = bouncier.
- **Response** is how quickly the value reaches the target, in seconds. Lower = snappier. This is not a duration: a spring has no fixed duration, its settle time emerges from the parameters.
Default most UI to **damping 1.0** (critically damped): graceful and non-distracting. Add bounce (**damping ~0.8**) only when the gesture itself carried momentum (a flick, a throw, a drag release). Overshoot on a menu that just faded in feels wrong; overshoot on a card you flicked feels right.
Values Apple ships:
| Interaction | Damping | Response |
|---|---|---|
| Move / reposition (e.g. PiP) | `1.0` | `0.4` |
| Rotation | `0.8` | `0.4` |
| Drawer / sheet | `0.8` | `0.3` |
**Web mapping:** Motion's `bounce` + `duration` spring API maps closely to Apple's damping + response. A safe house style is critically damped springs everywhere by default; reserve bounce for momentum-driven, physical interactions.
```js
// Critically damped default (no overshoot)
animate(el, { y: 0 }, { type: "spring", bounce: 0, duration: 0.4 });
// Momentum interaction: a little bounce, only because a flick preceded it
animate(el, { y: target }, { type: "spring", bounce: 0.2, duration: 0.4 });
```
## Interruptibility advantage
Springs keep velocity when interrupted; CSS keyframes restart from zero. Ideal for gestures users might change mid-motion.
```tsx
// Spring reverses smoothly from current position
<motion.div
animate={{ transform: isOpen ? "translateX(0)" : "translateX(-100%)" }}
transition={{ type: "spring", stiffness: 500, damping: 40 }}
/>
```
Three rules make interruption feel seamless:
- **Animate from the presentation value, never the logical target.** On interrupt, read the element's live on-screen transform and start the new animation from there. Starting from the target value causes a visible jump. (A closing modal the user grabs again should follow the finger, not finish closing first and then reopen.) Springs do this by default; CSS transitions and keyframes cannot be grabbed and reversed mid-flight, so avoid them for gesture-driven motion.
- **Carry velocity through a retarget.** Replacing one animation with another at a reversal creates a velocity discontinuity, a "brick wall". Pick a spring library that re-targets from the current velocity (iOS does this natively with additive animations).
- **Decompose 2D motion into independent X and Y springs.** A single spring on a 2D distance desyncs when X and Y have different velocities.
## Spring-based mouse interactions
Tying values directly to mouse position feels artificial. Use `useSpring` to interpolate instead of updating immediately.
```tsx
import { useSpring } from "framer-motion";
// Without spring: instant, feels artificial
const rotation = mouseX * 0.1;
// With spring: has momentum, feels natural
const springRotation = useSpring(mouseX * 0.1, {
stiffness: 100,
damping: 10,
});
```
Only for **decorative** interactions. On a functional graph in a banking app, no animation is better.
## Snap instead of spring
If the interaction needs instant response or precise timing, skip the spring: use a short transition or snap to the end state.
```tsx
<motion.div
animate={{ opacity: isOpen ? 1 : 0, x: isOpen ? 0 : -12 }}
transition={
shouldSnap
? { duration: 0.12, ease: "linear" }
: { type: "spring", stiffness: 500, damping: 40 }
}
/>
```
references/transition-recipes.md
# CSS Transition Recipes
12 CSS transition patterns. Each includes CSS, HTML hooks, JS orchestration where needed, and a `prefers-reduced-motion` guard. All read from a shared `:root` custom properties block.
## Contents
- [Custom properties](#custom-properties)
- [Card resize](#card-resize)
- [Panel reveal](#panel-reveal)
- [Notification badge](#notification-badge)
- [Icon swap](#icon-swap)
- [Menu dropdown](#menu-dropdown)
- [Modal dialog](#modal-dialog)
- [Text state swap](#text-state-swap)
- [Page side-by-side slides](#page-side-by-side-slides)
- [Number pop-in](#number-pop-in)
- [Avatar group hover](#avatar-group-hover)
- [Success celebration](#success-celebration)
- [Error state shake](#error-state-shake)
---
## Custom properties
Add this `:root` block once to your global stylesheet; every recipe reads these names.
```css
:root {
/* Card resize */
--resize-dur: 300ms;
--resize-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Number pop-in */
--digit-dur: 500ms;
--digit-dist: 12px;
--digit-stagger: 70ms;
--digit-blur: 6px;
--digit-ease: cubic-bezier(0.22, 1, 0.36, 1);
--digit-dir-x: 0;
--digit-dir-y: 1;
/* Notification badge */
--badge-slide-dur: 260ms;
--badge-pop-dur: 500ms;
--badge-blur: 2px;
--badge-offset-x: -8px;
--badge-offset-y: 12px;
--badge-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Text state swap */
--text-swap-dur: 150ms;
--text-swap-y: 4px;
--text-swap-blur: 2px;
--text-swap-ease: ease-in-out;
/* Menu dropdown */
--dropdown-open-dur: 250ms;
--dropdown-close-dur: 150ms;
--dropdown-pre-scale: 0.96;
--dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Modal dialog */
--modal-open-dur: 250ms;
--modal-close-dur: 150ms;
--modal-scale: 0.96;
--modal-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Panel reveal */
--panel-open-dur: 400ms;
--panel-close-dur: 350ms;
--panel-translate-y: 12px;
--panel-blur: 4px;
--panel-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Page side-by-side */
--page-dur: 200ms;
--page-dist: 8px;
--page-blur: 3px;
--page-stagger: 60ms;
--page-exit-enabled: 1;
--page-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Icon swap */
--icon-swap-dur: 200ms;
--icon-swap-blur: 2px;
--icon-swap-start-scale: 0.25;
--icon-swap-ease: ease-in-out;
/* Success celebration */
--success-opacity-dur: 550ms;
--success-rotate-dur: 550ms;
--success-bob-dur: 550ms;
--success-blur-dur: 400ms;
--success-path-dur: 550ms;
--success-path-delay: 80ms;
--success-rotate-from: 80deg;
--success-rotate-to: 0deg;
--success-bob-y: 40px;
--success-blur-from: 10px;
--success-ease: cubic-bezier(0.22, 1, 0.36, 1);
--success-bob-ease: cubic-bezier(0.34, 3.85, 0.64, 1);
/* Avatar group hover */
--avatar-lift: -4px;
--avatar-dur: 320ms;
--avatar-scale: 1.05;
--avatar-falloff: 0.45;
--avatar-ease-in: cubic-bezier(0.22, 1, 0.36, 1);
--avatar-ease-out: cubic-bezier(0.34, 3.85, 0.64, 1);
/* Error state shake */
--shake-dist: 4px;
--shake-overshoot: 2px;
--shake-dur-1: 80ms;
--shake-dur-2: 80ms;
--shake-dur-3: 60ms;
--shake-ease: cubic-bezier(0.36, 0.07, 0.19, 0.97);
--shake-revert-dur: 200ms;
--shake-hold: 1200ms;
}
```
---
## Card resize
Tween a container's width or height when its layout state changes (compact/expanded card, collapsing panel, list row toggling detail). CSS only, no JS.
```html
<div class="t-resize">Content</div>
```
```css
.t-resize {
transition: width var(--resize-dur) var(--resize-ease),
height var(--resize-dur) var(--resize-ease);
will-change: width, height;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.t-resize { transition: none; }
}
```
Toggle dimensions with a state class or inline style; the transition handles the tween.
---
## Panel reveal
Slide a panel into an existing container with cross-blur. CSS only, toggle `data-open`.
See also: `component-patterns.md` § Drawers and panels for percentage-based drawer slides.
```html
<div class="t-panel" data-open="false">Panel content</div>
```
```css
.t-panel {
opacity: 0;
transform: translateY(var(--panel-translate-y));
filter: blur(var(--panel-blur));
transition: opacity var(--panel-open-dur) var(--panel-ease),
transform var(--panel-open-dur) var(--panel-ease),
filter var(--panel-open-dur) var(--panel-ease);
}
.t-panel[data-open="true"] {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
.t-panel[data-open="false"] {
transition-duration: var(--panel-close-dur);
}
@media (prefers-reduced-motion: reduce) {
.t-panel { transition: none; }
}
```
---
## Notification badge
Slide a small badge onto a trigger (button, icon) and pop the dot; the trigger stays put. CSS only, toggle `data-open`.
```html
<button style="position: relative">
Inbox
<span class="t-badge" data-open="false">
<span class="t-badge-dot"></span>
</span>
</button>
```
```css
.t-badge {
position: absolute;
opacity: 0;
transform: translate(var(--badge-offset-x), var(--badge-offset-y));
filter: blur(var(--badge-blur));
transition: opacity var(--badge-slide-dur) var(--badge-ease),
transform var(--badge-slide-dur) var(--badge-ease),
filter var(--badge-slide-dur) var(--badge-ease);
}
.t-badge[data-open="true"] {
opacity: 1;
transform: translate(0, 0);
filter: blur(0);
}
.t-badge-dot {
display: block;
width: 8px; height: 8px;
border-radius: 50%;
background: currentColor;
transform: scale(0);
transition: transform var(--badge-pop-dur) var(--badge-ease);
}
.t-badge[data-open="true"] .t-badge-dot {
transform: scale(1);
transition-delay: calc(var(--badge-slide-dur) * 0.5);
}
@media (prefers-reduced-motion: reduce) {
.t-badge, .t-badge-dot { transition: none; animation: none; }
}
```
---
## Icon swap
Cross-fade two icons in one slot (hamburger/close, play/pause). CSS grid stacks both. Toggle `data-state`.
See also: `contextual-animations.md` § Contextual icon swaps for the Motion/AnimatePresence approach.
```html
<span class="t-icon-swap" data-state="a">
<span class="t-icon" data-icon="a">☰</span>
<span class="t-icon" data-icon="b">✕</span>
</span>
```
```css
.t-icon-swap {
display: inline-grid;
}
.t-icon {
grid-area: 1 / 1;
opacity: 0;
transform: scale(var(--icon-swap-start-scale));
filter: blur(var(--icon-swap-blur));
transition: opacity var(--icon-swap-dur) var(--icon-swap-ease),
transform var(--icon-swap-dur) var(--icon-swap-ease),
filter var(--icon-swap-dur) var(--icon-swap-ease);
}
.t-icon-swap[data-state="a"] [data-icon="a"],
.t-icon-swap[data-state="b"] [data-icon="b"] {
opacity: 1;
transform: scale(1);
filter: blur(0);
}
@media (prefers-reduced-motion: reduce) {
.t-icon { transition: none; }
}
```
---
## Menu dropdown
Origin-aware dropdown with open/close animations. JS handles close-state cleanup.
See also: `component-patterns.md` § Popovers and dropdowns for Radix UI transform-origin and scale patterns.
```html
<div class="t-dropdown" data-origin="top-left">
Menu content
</div>
```
```css
.t-dropdown {
opacity: 0;
transform: scale(var(--dropdown-pre-scale));
transition: opacity var(--dropdown-open-dur) var(--dropdown-ease),
transform var(--dropdown-open-dur) var(--dropdown-ease);
}
.t-dropdown.is-open {
opacity: 1;
transform: scale(1);
}
.t-dropdown.is-closing {
opacity: 0;
transform: scale(0.99);
transition-duration: var(--dropdown-close-dur);
}
.t-dropdown[data-origin="top-left"] { transform-origin: top left; }
.t-dropdown[data-origin="top-center"] { transform-origin: top center; }
.t-dropdown[data-origin="top-right"] { transform-origin: top right; }
.t-dropdown[data-origin="bottom-left"] { transform-origin: bottom left; }
.t-dropdown[data-origin="bottom-center"]{ transform-origin: bottom center; }
.t-dropdown[data-origin="bottom-right"] { transform-origin: bottom right; }
@media (prefers-reduced-motion: reduce) {
.t-dropdown { transition: none; }
}
```
**JS, close with cleanup:**
```js
function closeDropdown(el) {
el.classList.add("is-closing");
el.classList.remove("is-open");
const dur = parseFloat(getComputedStyle(el).getPropertyValue("--dropdown-close-dur"));
setTimeout(() => el.classList.remove("is-closing"), dur);
}
```
---
## Modal dialog
Scale-up modal with softer scale-down on close. Class-based state.
See also: `component-patterns.md` § Modals and dialogs for `@starting-style` entry pattern.
```html
<div class="t-modal" role="dialog">Modal content</div>
```
```css
.t-modal {
opacity: 0;
transform: scale(var(--modal-scale));
transform-origin: center;
transition: opacity var(--modal-open-dur) var(--modal-ease),
transform var(--modal-open-dur) var(--modal-ease);
}
.t-modal.is-open {
opacity: 1;
transform: scale(1);
}
.t-modal.is-closing {
opacity: 0;
transform: scale(var(--modal-scale));
transition-duration: var(--modal-close-dur);
}
@media (prefers-reduced-motion: reduce) {
.t-modal { transition: none; }
}
```
**JS, close with cleanup:**
```js
function closeModal(el) {
el.classList.add("is-closing");
el.classList.remove("is-open");
const dur = parseFloat(getComputedStyle(el).getPropertyValue("--modal-close-dur"));
setTimeout(() => el.classList.remove("is-closing"), dur);
}
```
---
## Text state swap
Swap text in place with a blurred vertical transition ("Processing..." → "Done"). JS coordinates the three phases.
```html
<span class="t-text-swap">Processing...</span>
```
```css
.t-text-swap {
display: inline-block;
transition: opacity var(--text-swap-dur) var(--text-swap-ease),
transform var(--text-swap-dur) var(--text-swap-ease),
filter var(--text-swap-dur) var(--text-swap-ease);
}
.t-text-swap.is-exit {
opacity: 0;
transform: translateY(calc(-1 * var(--text-swap-y)));
filter: blur(var(--text-swap-blur));
}
.t-text-swap.is-enter-start {
opacity: 0;
transform: translateY(var(--text-swap-y));
filter: blur(var(--text-swap-blur));
}
@media (prefers-reduced-motion: reduce) {
.t-text-swap { transition: none; }
}
```
**JS, three-phase orchestration:**
```js
function swapText(el, newText) {
const dur = parseFloat(getComputedStyle(el).getPropertyValue("--text-swap-dur"));
el.classList.add("is-exit");
setTimeout(() => {
el.textContent = newText;
el.classList.remove("is-exit");
el.classList.add("is-enter-start");
void el.offsetWidth; // force reflow
el.classList.remove("is-enter-start");
}, dur);
}
```
---
## Page side-by-side slides
Slide between two adjacent pages (list/detail, wizard steps). Page 1 exits left, page 2 enters right.
See also: `component-patterns.md` § Step form navigation for the Motion/AnimatePresence approach with direction variants.
```html
<div class="t-page-slide" data-page="1">
<section data-page-id="1">Page 1</section>
<section data-page-id="2">Page 2</section>
</div>
```
```css
.t-page-slide {
display: grid;
overflow: hidden;
}
.t-page-slide > * {
grid-area: 1 / 1;
transition: opacity var(--page-dur) var(--page-ease),
transform var(--page-dur) var(--page-ease),
filter var(--page-dur) var(--page-ease);
}
/* Page 1 active */
.t-page-slide[data-page="1"] [data-page-id="1"] {
opacity: 1; transform: translateX(0); filter: blur(0);
}
.t-page-slide[data-page="1"] [data-page-id="2"] {
opacity: 0;
transform: translateX(var(--page-dist));
filter: blur(var(--page-blur));
}
/* Page 2 active */
.t-page-slide[data-page="2"] [data-page-id="2"] {
opacity: 1; transform: translateX(0); filter: blur(0);
}
.t-page-slide[data-page="2"] [data-page-id="1"] {
opacity: 0;
transform: translateX(calc(-1 * var(--page-dist)));
filter: blur(var(--page-blur));
}
@media (prefers-reduced-motion: reduce) {
.t-page-slide > * { transition: none; }
}
```
**JS, switch page:**
```js
slider.setAttribute("data-page", String(n));
```
Set `--page-exit-enabled: 0` for fade-only, no slide (useful on initial load).
---
## Number pop-in
Re-enter digits with directional blur on number update (counters, prices, balances). Each digit animates individually with optional stagger.
```html
<span class="t-digits">
<span class="t-digit">1</span>
<span class="t-digit">2</span>
<span class="t-digit" data-stagger="1">3</span>
<span class="t-digit" data-stagger="2">4</span>
</span>
```
```css
.t-digits {
display: inline-flex;
}
@keyframes digit-enter {
from {
opacity: 0;
transform: translate(
calc(var(--digit-dir-x) * var(--digit-dist)),
calc(var(--digit-dir-y) * var(--digit-dist))
);
filter: blur(var(--digit-blur));
}
}
.t-digit {
display: inline-block;
animation: digit-enter var(--digit-dur) var(--digit-ease) both;
}
.t-digit[data-stagger="1"] { animation-delay: var(--digit-stagger); }
.t-digit[data-stagger="2"] { animation-delay: calc(var(--digit-stagger) * 2); }
@media (prefers-reduced-motion: reduce) {
.t-digit { animation: none; }
}
```
**JS, replay on update:**
```js
function updateDigits(container, newValue) {
container.classList.remove("is-animating");
container.innerHTML = String(newValue)
.split("")
.map((d, i, arr) => {
const stagger = i >= arr.length - 2 ? ` data-stagger="${arr.length - 1 - i}"` : "";
return `<span class="t-digit"${stagger}>${d}</span>`;
})
.join("");
void container.offsetWidth; // force reflow
container.classList.add("is-animating");
}
```
---
## Avatar group hover
Distance-falloff lift on a horizontal stack. Hovered item lifts and scales; neighbors lift less with distance. Bouncy spring on leave.
```html
<div class="t-avatar-group">
<div class="t-avatar">A</div>
<div class="t-avatar">B</div>
<div class="t-avatar">C</div>
</div>
```
```css
.t-avatar-group {
display: flex;
gap: 4px;
}
.t-avatar {
transition: transform var(--avatar-dur) var(--avatar-ease-in);
}
@media (prefers-reduced-motion: reduce) {
.t-avatar { transition: none; }
}
```
**JS, distance-based lift:**
Set `transition-timing-function` inline *before* writing CSS variables. The browser applies whatever timing function is current when the property changes, giving smooth ease-in on hover and bouncy ease-out on return without separate declarations.
```js
const group = document.querySelector(".t-avatar-group");
const items = [...group.querySelectorAll(".t-avatar")];
const lift = parseFloat(getComputedStyle(group).getPropertyValue("--avatar-lift"));
const scale = parseFloat(getComputedStyle(group).getPropertyValue("--avatar-scale"));
const falloff = parseFloat(getComputedStyle(group).getPropertyValue("--avatar-falloff"));
group.addEventListener("mouseenter", (e) => {
const target = e.target.closest(".t-avatar");
if (!target) return;
const idx = items.indexOf(target);
items.forEach((item, i) => {
const dist = Math.abs(i - idx);
item.style.transitionTimingFunction = "var(--avatar-ease-in)";
if (dist === 0) {
item.style.transform = `translateY(${lift}px) scale(${scale})`;
} else {
const y = lift * Math.pow(falloff, dist);
item.style.transform = `translateY(${y}px)`;
}
});
}, true);
group.addEventListener("mouseleave", () => {
items.forEach((item) => {
item.style.transitionTimingFunction = "var(--avatar-ease-out)";
item.style.transform = "";
});
});
```
---
## Success celebration
Multi-layered success: fade, rotation, blur reduction, Y-bob with overshoot, optional SVG stroke draw. Toggle `data-state` to `"in"`.
```html
<div class="t-success" data-state="out">
<svg><path class="t-success-path" d="..." /></svg>
</div>
```
```css
.t-success {
opacity: 0;
transform: rotate(var(--success-rotate-from)) translateY(var(--success-bob-y));
filter: blur(var(--success-blur-from));
}
@keyframes success-in {
0% {
opacity: 0;
transform: rotate(var(--success-rotate-from)) translateY(var(--success-bob-y));
filter: blur(var(--success-blur-from));
}
100% {
opacity: 1;
transform: rotate(var(--success-rotate-to)) translateY(0);
filter: blur(0);
}
}
.t-success[data-state="in"] {
animation: success-in var(--success-opacity-dur) var(--success-ease) forwards;
}
.t-success-path {
stroke-dashoffset: var(--path-length);
stroke-dasharray: var(--path-length);
transition: stroke-dashoffset var(--success-path-dur) var(--success-ease);
transition-delay: var(--success-path-delay);
}
.t-success[data-state="in"] .t-success-path {
stroke-dashoffset: 0;
}
@media (prefers-reduced-motion: reduce) {
.t-success { animation: none; opacity: 1; transform: none; filter: none; }
.t-success-path { transition: none; stroke-dashoffset: 0; }
}
```
**JS, set path length and replay:**
Never hardcode `stroke-dasharray`. Use `getTotalLength()` to measure the actual path.
```js
function playSuccess(el) {
const path = el.querySelector(".t-success-path");
if (path) {
const len = path.getTotalLength();
el.style.setProperty("--path-length", len);
}
el.setAttribute("data-state", "out");
void el.offsetWidth; // force reflow to restart keyframes
el.setAttribute("data-state", "in");
}
```
---
## Error state shake
Per-segment shake with auto-reverting error border. Three classes: `.is-error` on wrapper and input, `.is-shaking` on input only.
```html
<div class="t-error-wrap">
<input class="t-error-input" />
<p class="t-error-msg">Invalid email</p>
</div>
```
```css
@keyframes shake {
0% { transform: translateX(0); }
25% { transform: translateX(var(--shake-dist)); }
50% { transform: translateX(calc(-1 * var(--shake-overshoot))); }
75% { transform: translateX(calc(var(--shake-dist) * 0.5)); }
100% { transform: translateX(0); }
}
.t-error-input {
transition: border-color var(--shake-revert-dur) ease;
}
.t-error-input.is-error {
border-color: var(--color-error, #ef4444);
}
.t-error-input.is-shaking {
animation: shake
calc(var(--shake-dur-1) + var(--shake-dur-2) + var(--shake-dur-3))
var(--shake-ease);
}
.t-error-msg {
opacity: 0;
transform: translateY(-4px);
transition: opacity 150ms ease, transform 150ms ease;
}
.t-error-wrap.is-error .t-error-msg {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.t-error-input { animation: none; }
.t-error-msg { transition: none; }
}
```
**JS, trigger and auto-revert:**
Keep `.is-error` and `.is-shaking` separate. `.is-shaking` controls only the shake animation, removed on `animationend`. `.is-error` controls the border colour and message visibility, auto-reverting after the hold duration.
```js
function triggerError(wrap, input) {
wrap.classList.add("is-error");
input.classList.add("is-error", "is-shaking");
input.addEventListener("animationend", () => {
input.classList.remove("is-shaking");
}, { once: true });
const hold = parseFloat(getComputedStyle(wrap).getPropertyValue("--shake-hold"));
setTimeout(() => {
wrap.classList.remove("is-error");
input.classList.remove("is-error");
}, hold);
}
```
references/vocabulary.md
# Animation Vocabulary
Reverse-lookup glossary: turn a vague description of a motion or effect into the precise term, so the user knows what to ask for.
## Contents
- [How to answer](#how-to-answer)
- [Examples](#examples)
- [Glossary](#glossary)
## How to answer
The user describes an effect loosely; you return the matching term(s) in this format:
```
**Stagger**: Animate several items one after another with a small delay between each, creating a cascade.
```
If several terms fit, lead with the best match, then 1-2 alternates with a one-line note on how they differ.
1. **Read for intent, not keywords.** Users describe what they see or feel ("springy", "slides off", "draws itself in"), not the technical name. Map the sensation to the glossary.
2. **Quote the glossary verbatim.** Its descriptions are authoritative; use them as-is.
3. **Disambiguate close terms.** When two compete (clip-path vs mask, pop in vs bounce, shared element transition vs layout animation), contrast them so the user can pick.
4. **When nothing matches exactly,** name the closest term and say plainly it's an approximation, or describe the effect in the glossary's vocabulary ("that's a stagger of scale-in entrances").
5. **Stay within this glossary.** If a term genuinely isn't here, say so rather than inventing one, though you may explain the concept using these words.
6. **Keep it tight.** A naming question wants a name, not an essay. Lead with the term; expand only if asked.
## Examples
**Feel-based**
User: "What's it called when a popover seems to grow out of the button you clicked instead of from its middle?"
Answer: **Origin-aware animation**: An element animates out of its trigger, like a popover growing from the button that opened it instead of from its own center, which is the default in CSS.
**Disambiguation**
User: "The thing where one image turns into another image."
Answer: **Morph**: One shape smoothly turns into another shape, e.g. Dynamic Island. Close alternates: **Crossfade** if they simply fade over each other in the same spot; **Shared element transition** if an element travels and transforms from one position into another.
**Physics feel**
User: "That iOS scroll where it resists and snaps back when you pull too far."
Answer: **Rubber-banding**: Resistance and snap-back when you drag past a boundary (the iOS overscroll feel).
## Glossary
### Entrances and exits: how elements appear and disappear
- **Fade in / Fade out**: Element appears or disappears by changing opacity.
- **Slide in**: Element enters by sliding in from off-screen (left, right, top, or bottom).
- **Scale in**: Element grows from smaller to full size as it appears, often paired with a fade.
- **Pop in**: Element appears with a slight overshoot, like it bounces into place.
- **Reveal**: Content is uncovered gradually, often by animating a clip-path or mask.
- **Enter / Exit**: The animation an element plays when it's added to or removed from the screen.
### Sequencing and timing: coordinating multiple elements or moments
- **Keyframes**: Defined points in an animation (0%, 50%, 100%) that the browser fills the gaps between.
- **Interpolation / Tween**: Generating all the in-between frames between a start and end value, so motion is continuous.
- **Stagger**: Animate several items one after another with a small delay between each, creating a cascade.
- **Orchestration**: Deliberately timing multiple animations so they feel like one coordinated motion.
- **Delay**: Time before an animation starts.
- **Duration**: How long an animation takes.
- **Fill mode**: Whether an element keeps its first or last frame's styles before the animation starts or after it ends (e.g. forwards).
- **Stepped animation**: An animation divided into discrete steps, like a countdown timer.
### Movement and transforms: changing an element's position, size, or angle
- **Translate**: Move an element along the X or Y axis.
- **Scale**: Make an element bigger or smaller.
- **Rotate**: Spin an element around a point.
- **Skew**: Slant an element along the X or Y axis, shearing it out of its rectangular shape.
- **3D tilt / Flip**: Rotate in 3D space (rotateX / rotateY) to add depth.
- **Perspective**: How strong the 3D effect looks; a lower value exaggerates depth, like the viewer is closer.
- **Transform origin**: The anchor point a scale or rotation grows or spins from.
- **Origin-aware animation**: An element animates out of its trigger, like a popover growing from the button that opened it instead of from its own center, which is the default in CSS.
### Transitions between states: connecting one state, view, or element to another
- **Crossfade**: One element fades out as another fades in, in the same spot.
- **Continuity transition**: A change that keeps the user oriented by visually connecting before and after. For example, making the same rectangle bigger and smaller.
- **Morph**: One shape smoothly turns into another shape, e.g. Dynamic Island.
- **Shared element transition**: An element travels and transforms from one position into another, like a thumbnail expanding into a card.
- **Layout animation**: When an element's size or position changes, it animates to the new spot instead of snapping.
- **Accordion / Collapse**: A section smoothly expands and collapses its height to show or hide content.
- **Direction-aware transition**: Content slides one way going forward and the opposite way going back, so navigation has a sense of direction.
### Scroll: motion tied to scrolling or navigating between views
- **Scroll reveal**: Elements fade or slide into place as they enter the viewport.
- **Scroll-driven animation**: An animation whose progress is tied directly to scroll position.
- **Parallax**: Background and foreground move at different speeds while scrolling, creating depth.
- **Page transition**: An animation that plays when navigating from one page or route to another.
- **View transition**: The browser morphs between two states or pages, connecting shared elements.
### Feedback and interaction: responding to the user's actions
- **Hover effect**: Visual change when the cursor moves over an element.
- **Press / Tap feedback**: A subtle scale-down when an element is clicked, so it feels physical.
- **Hold to confirm**: A progress effect that fills up while the user holds a button.
- **Drag**: Moving an element by grabbing it, often with momentum when released.
- **Drag to reorder**: Dragging items in a list to rearrange them, while the others shift to make room.
- **Swipe to dismiss**: Dragging an element off-screen to close it, like a drawer or toast.
- **Rubber-banding**: Resistance and snap-back when you drag past a boundary (the iOS overscroll feel).
- **Shake / Wiggle**: A quick side-to-side jitter signaling an error or rejected input.
- **Ripple**: A circle expanding from the point of a tap, confirming the press.
### Easing: how speed changes over an animation
- **Easing**: The rate at which an animation speeds up or slows down.
- **Ease-out**: Starts fast, ends slow. The default for most UI and anything responding to the user.
- **Ease-in**: Starts slow, ends fast. Usually avoided; can feel sluggish.
- **Ease-in-out**: Slow, fast, slow. Good for elements already on screen moving from A to B.
- **Linear**: Constant speed. Avoid for UI; reserve for spinners or marquees.
- **Cubic-bezier**: A custom easing curve you define for precise control.
- **Asymmetric easing**: A curve that accelerates and decelerates at different rates. Feels more alive than a symmetric one.
### Spring animations: physics-based motion as an alternative to fixed-duration easing
- **Spring**: Motion driven by physics (tension, mass, damping) rather than a set duration.
- **Stiffness / Tension**: How strongly the spring pulls toward its target. Higher feels snappier.
- **Damping**: How quickly a spring settles. Lower damping means more bounce and oscillation.
- **Mass**: How heavy the animated element feels. More mass makes it slower and more sluggish.
- **Bounce**: A spring that overshoots and settles, adding playfulness.
- **Perceptual duration**: How long a spring feels finished, even though it keeps micro-settling underneath.
- **Momentum**: Motion that carries velocity, especially after a drag or interruption.
- **Velocity**: How fast and in which direction an element is moving. A spring carries it into the next animation when interrupted, so a flicked element keeps its speed.
- **Interruptible animation**: An animation that can be smoothly redirected mid-flight instead of finishing first.
### Looping and ambient motion: animations that run on their own
- **Marquee**: Text or content that scrolls continuously in a loop.
- **Loop**: An animation that repeats, a set number of times or infinitely.
- **Alternate (yoyo)**: A loop that plays forward then reverses each iteration, instead of jumping back to the start.
- **Orbit**: An element circling around another in a continuous path.
- **Pulse**: A gentle repeating scale or opacity change to draw attention.
- **Float**: A gentle, continuous up-and-down drift that makes a static element feel alive and weightless.
- **Idle animation**: Subtle motion that plays while an element is just sitting there, waiting to be interacted with.
### Polish and effects: the small touches that separate good from great
- **Blur**: A blur filter used to soften an element or mask tiny imperfections.
- **Clip-path**: Clipping an element to a shape, used for reveals, masks, and before/after sliders.
- **Mask**: Hiding or revealing parts of an element using a shape or gradient, like clip-path but with soft, fadeable edges.
- **Before / after slider**: A draggable divider that wipes between two overlaid images to compare them.
- **Line drawing**: An SVG path that draws itself in, like an invisible pen tracing it.
- **Text morph**: Text that animates character by character when it changes, drawing attention to the new value.
- **Skeleton / Shimmer**: A placeholder with a moving sheen shown while content loads.
- **Number ticker**: Digits rolling or counting up to a value.
- **Tabular numbers**: Fixed-width digits so numbers don't shift around as they change. Essential for tickers, timers, and counters.
- **Typewriter**: Text appearing one character at a time, as if being typed.
### Performance: what keeps motion smooth instead of stuttering
- **Frame rate (FPS)**: Frames drawn per second. 60fps is the baseline for smooth motion; 120fps on newer displays.
- **Jank**: Visible stutter when the browser drops frames because it can't keep up with the animation.
- **Dropped frame**: A frame the browser missed its deadline to draw, causing a tiny hitch in motion.
- **Compositing**: Letting the GPU move or fade an element on its own layer without redoing layout or paint.
- **will-change**: A CSS hint that an element is about to animate, so the browser can promote it to its own layer ahead of time.
- **Layout thrashing**: Animating properties like width, height, top, or left that force the browser to recalculate layout every frame, causing jank.
### Principles to know: concepts that guide when and how to animate
- **Purposeful animation**: Motion should serve a function (orient, give feedback, show relationships), not just decorate.
- **Anticipation**: A small wind-up in the opposite direction before a move, hinting at what's about to happen.
- **Follow-through**: Parts of an element keep moving and settle slightly after the main motion stops, adding weight.
- **Squash and stretch**: Deforming an element as it moves to convey weight, speed, and flexibility.
- **Perceived performance**: The right animation makes an interface feel faster, even when it isn't.
- **Frequency of use**: The more often a user sees an animation, the shorter and subtler it should be.
- **Spatial consistency**: Animating so an element keeps its identity and position across states, so users never lose track of where things went.
- **Hardware acceleration**: Animating transform and opacity lets the GPU keep motion smooth.
- **Reduced motion**: Respecting the user's prefers-reduced-motion setting by toning down or removing motion.
scripts/extract_frames.py
#!/usr/bin/env python3
"""Extract frames from a screen recording and build a contact sheet.
Run:
python3 scripts/extract_frames.py <video> <outdir> [--fps N] [--cols C]
[--start SECONDS] [--duration SECONDS]
On a multi-second recording, trim to just the transition with --start/--duration;
extracting the whole clip floods the contact sheet and dilutes tracking.
Produces:
<outdir>/frame_0001.png ... zero-padded, one per sampled frame
<outdir>/contact_sheet.png montage of every frame, left-to-right top-to-bottom
The contact sheet lets one vision read cover the whole timeline. Open it first,
then drill into individual frames only where the motion is interesting.
Only ffmpeg is required for this script. Tracking/fitting need extra packages.
"""
import argparse
import os
import shutil
import subprocess
import sys
# UI transitions of interest are usually 0.3-1.0s. 30 fps captures the
# sub-frame easing detail (over-stretch, settle, bounce) that lower rates blur
# together, while staying cheap to look at. Override with --fps for slow/long clips.
DEFAULT_FPS = 30
# Contact-sheet column count. ~8 keeps each frame large enough to read on a
# typical clip (a 0.5s clip at 30fps -> 15 frames -> 2 rows) without shrinking
# thumbnails to mush. Override with --cols for very long clips.
DEFAULT_COLS = 8
def require_ffmpeg():
if shutil.which("ffmpeg") is None:
sys.exit(
"ffmpeg not found on PATH. Install it first:\n"
" macOS: brew install ffmpeg\n"
" Debian: sudo apt-get install ffmpeg"
)
def run(cmd):
"""Run a command, surfacing ffmpeg's own error text on failure."""
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
sys.exit(f"command failed: {' '.join(cmd)}\n{proc.stderr.strip()}")
return proc
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("video", help="path to the screen recording (mp4, mov, gif, ...)")
p.add_argument("outdir", help="directory for extracted frames (created if missing)")
p.add_argument("--fps", type=int, default=DEFAULT_FPS, help=f"frames per second to sample (default {DEFAULT_FPS})")
p.add_argument("--cols", type=int, default=DEFAULT_COLS, help=f"contact-sheet columns (default {DEFAULT_COLS})")
p.add_argument("--start", type=float, default=None, help="window start in seconds (trim long recordings)")
p.add_argument("--duration", type=float, default=None, help="window length in seconds from --start")
args = p.parse_args()
require_ffmpeg()
if not os.path.isfile(args.video):
sys.exit(f"video not found: {args.video}")
os.makedirs(args.outdir, exist_ok=True) # solve, don't punt: create it
# Trim as OUTPUT seeking (-ss/-t AFTER -i): frame-accurate, which matters for a
# sub-second window. Input seeking (before -i) is faster but snaps to keyframes
# and can miss the first frames of the transition.
window = []
if args.start is not None:
window += ["-ss", str(args.start)]
if args.duration is not None:
window += ["-t", str(args.duration)]
frame_glob = os.path.join(args.outdir, "frame_%04d.png")
run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", args.video, *window, "-vf", f"fps={args.fps}", frame_glob])
frames = sorted(f for f in os.listdir(args.outdir) if f.startswith("frame_") and f.endswith(".png"))
if not frames:
sys.exit("ffmpeg produced no frames. Is the video readable and non-empty?")
rows = (len(frames) + args.cols - 1) // args.cols
sheet = os.path.join(args.outdir, "contact_sheet.png")
run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-pattern_type", "glob", "-i", os.path.join(args.outdir, "frame_*.png"),
"-vf", f"tile={args.cols}x{rows}:padding=4:color=white", "-frames:v", "1", sheet])
win = ""
if window:
end = "" if args.duration is None else f"-{(args.start or 0) + args.duration:g}s"
win = f" (window {args.start or 0:g}s{end})"
print(f"extracted {len(frames)} frames at {args.fps} fps{win} -> {args.outdir}")
print(f"contact sheet: {sheet} ({args.cols}x{rows})")
print("Open the contact sheet first to read the whole timeline at once.")
if __name__ == "__main__":
main()
scripts/fit_curves.py
#!/usr/bin/env python3
"""Fit easing curves to a per-frame metrics timeline.
Run:
python3 scripts/fit_curves.py <metrics.json> [--fps N] [--property NAME]
For each animated property (position, scale, opacity, ...) this fits two models
to its normalized 0->1 progress over time:
spring damped harmonic oscillator -> { stiffness, damping, mass }
bezier cubic-bezier easing -> { x1, y1, x2, y2 }
Both report a normalized RMS fit error. Lower is better; a spring that clearly
overshoots will beat a bezier (and vice versa). Read references/curve-fitting.md
to interpret the numbers and pick the model. A high error on BOTH usually means
the motion is multi-phase: split it and fit each phase separately.
Requires: numpy, scipy (pip install numpy scipy)
"""
import argparse
import json
import sys
try:
import numpy as np
from scipy.optimize import curve_fit
except ImportError:
sys.exit("missing deps. Install with: pip install numpy scipy")
DEFAULT_FPS = 30 # must match the fps used in extract_frames.py
# Properties to attempt, mapped to the timeline field(s) that express them.
# Position uses centroid distance from the first tracked frame. Width and height
# are fit as SEPARATE axes: a "fluid" morph over-stretches one axis (usually
# vertical) ahead of the other, and the edges settle independently; fitting a
# single uniform scale would erase that signature effect.
PROPERTIES = {
"translate": ("cx", "cy"),
"scaleX": ("width",),
"scaleY": ("height",),
"opacity": ("opacity",),
"blur": ("blur",),
"radius": ("radius",),
}
def progress(series):
"""Normalize a raw measurement series to 0->1 progress along its own range.
Returns None if the property barely changes (range below a hair of its scale),
so we don't fit curves to noise.
"""
a = np.asarray(series, float)
lo, hi = a.min(), a.max()
span = hi - lo
ref = max(abs(hi), abs(lo), 1.0)
if span < 0.01 * ref: # <1% movement: property is effectively static
return None
return (a - a[0]) / (a[-1] - a[0]) if a[-1] != a[0] else (a - lo) / span
def spring_model(t, zeta, omega):
"""Unit-step response of a 2nd-order system, normalized to settle at 1.
Underdamped (zeta<1) overshoots and rings; critically/over-damped eases in.
We fit (zeta, omega) then convert to stiffness/damping/mass at the end.
"""
t = np.asarray(t, float)
if zeta < 1.0:
wd = omega * np.sqrt(max(1 - zeta * zeta, 1e-9))
phi = np.arctan2(zeta, np.sqrt(max(1 - zeta * zeta, 1e-9)))
return 1 - np.exp(-zeta * omega * t) * np.cos(wd * t - phi) / np.cos(phi)
return 1 - (1 + omega * t) * np.exp(-omega * t) # critically-damped form
def bezier_progress(t, x1, y1, x2, y2):
"""Sample a cubic-bezier easing y for each x=t in [0,1] (CSS timing-function)."""
t = np.clip(np.asarray(t, float), 0, 1)
# Solve bezier-x(s)=t for parameter s by bisection, then return bezier-y(s).
s = np.full_like(t, 0.5)
lo, hi = np.zeros_like(t), np.ones_like(t)
for _ in range(40): # 40 bisections -> ~1e-12 precision, ample for easing
bx = 3 * (1 - s) ** 2 * s * x1 + 3 * (1 - s) * s ** 2 * x2 + s ** 3
lo = np.where(bx < t, s, lo)
hi = np.where(bx >= t, s, hi)
s = (lo + hi) / 2
return 3 * (1 - s) ** 2 * s * y1 + 3 * (1 - s) * s ** 2 * y2 + s ** 3
def rms(a, b):
return float(np.sqrt(np.mean((np.asarray(a) - np.asarray(b)) ** 2)))
def fit_property(prog, t):
out = {}
# Spring fit. Bounded so the optimizer stays in physically sane territory:
# zeta in (0,2] spans bouncy->overdamped; omega in (0,50] covers fast UI.
# p0 starts mid-range, zeta 0.6 (mildly bouncy) at omega 8 rad/s (~0.8s
# settle), so the optimizer can converge toward either extreme.
try:
(zeta, omega), _ = curve_fit(spring_model, t, prog, p0=[0.6, 8.0],
bounds=([0.01, 0.1], [2.0, 50.0]), maxfev=10000)
pred = spring_model(t, zeta, omega)
# Convert to the (stiffness, damping, mass) triple APIs expect, fixing mass=1.
mass = 1.0
stiffness = omega * omega * mass
damping = 2 * zeta * omega * mass
out["spring"] = {
"stiffness": round(float(stiffness), 1),
"damping": round(float(damping), 2),
"mass": mass,
"zeta": round(float(zeta), 3),
"overshoot": bool(zeta < 1.0),
"error": round(rms(pred, prog), 4),
}
except (RuntimeError, ValueError):
out["spring"] = {"error": None, "note": "spring fit failed to converge"}
# Bezier fit. Control-point x in [0,1] (monotonic time), y bounded a bit
# past [0,1] (here ±0.5) so it can express overshoot like
# cubic-bezier(.2,1.4,.3,1). p0 = [0.25, 0.1, 0.25, 1.0] ≈ the CSS `ease`
# default, a neutral curve every UI easing is a small step away from.
try:
(x1, y1, x2, y2), _ = curve_fit(bezier_progress, t / t[-1], prog, p0=[0.25, 0.1, 0.25, 1.0],
bounds=([0, -0.5, 0, 0.5], [1, 1.5, 1, 1.5]), maxfev=10000)
pred = bezier_progress(t / t[-1], x1, y1, x2, y2)
out["bezier"] = {
"cubic_bezier": [round(float(x1), 3), round(float(y1), 3), round(float(x2), 3), round(float(y2), 3)],
"css": f"cubic-bezier({round(float(x1),3)}, {round(float(y1),3)}, {round(float(x2),3)}, {round(float(y2),3)})",
"error": round(rms(pred, prog), 4),
}
except (RuntimeError, ValueError):
out["bezier"] = {"error": None, "note": "bezier fit failed to converge"}
best = min((m for m in (out["spring"].get("error"), out["bezier"].get("error")) if m is not None), default=None)
if best is not None:
out["recommended"] = "spring" if out["spring"].get("error") == best else "bezier"
return out
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("metrics", help="metrics.json from track_motion.py")
p.add_argument("--fps", type=int, default=DEFAULT_FPS, help=f"fps used during extraction (default {DEFAULT_FPS})")
p.add_argument("--property", default=None, help="fit only this property (translate|scaleX|scaleY|opacity|blur|radius)")
args = p.parse_args()
with open(args.metrics) as f:
data = json.load(f)
tl = [r for r in data["timeline"] if r.get("present") is not False]
if len(tl) < 4:
sys.exit("need at least 4 tracked frames to fit a curve")
frames = np.array([r["frame"] for r in tl], float)
t = (frames - frames[0]) / args.fps # seconds from first tracked frame
duration_ms = round(float(t[-1] * 1000))
if args.property and args.property not in PROPERTIES:
sys.exit(f"unknown property '{args.property}'. Choose from: {', '.join(PROPERTIES)}")
wanted = {args.property: PROPERTIES[args.property]} if args.property else PROPERTIES
result = {"duration_ms": duration_ms, "tracked_frames": len(tl), "properties": {}}
for name, fields in wanted.items():
if name == "translate":
cx = np.array([r["cx"] for r in tl]); cy = np.array([r["cy"] for r in tl])
raw = np.hypot(cx - cx[0], cy - cy[0]) # distance travelled from start
else:
raw = np.array([r[fields[0]] for r in tl], float)
prog = progress(raw)
if prog is None:
continue # property didn't move enough to be worth fitting
result["properties"][name] = fit_property(prog, t)
if not result["properties"]:
sys.exit("no property changed enough to fit. Check that you tracked the right element.")
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
scripts/track_motion.py
#!/usr/bin/env python3
"""Measure a moving element across extracted frames, frame by frame.
Run:
python3 scripts/track_motion.py <framedir> [--out metrics.json]
[--bbox X,Y,W,H] [--invert] [--threshold T]
--bbox restricts frame-diff detection to that region (use when several elements
move and you want just one; run once per element). The element is still tracked
inside the region, so position/scale/opacity stay meaningful.
Outputs a JSON timeline of per-frame measurements for the tracked element:
x, y top-left of its bounding box (pixels)
cx, cy centroid (pixels)
width, height bounding-box size (pixels) -> scale proxy
opacity mean foreground alpha proxy in [0,1]
blur normalized inverse sharpness in [0,1] (1 = most blurred)
radius corner-roundness proxy in [0,1] (1 = most rounded)
These are PROXIES from pixels, not ground truth. Read references/measurement-guide.md
for what each one means and when to trust it. Feed the JSON to fit_curves.py.
Requires: opencv-python, numpy (pip install opencv-python numpy)
"""
import argparse
import json
import os
import sys
try:
import cv2
import numpy as np
except ImportError:
sys.exit("missing deps. Install with: pip install opencv-python numpy")
# Frames whose foreground mask covers less than this fraction of the searched
# area (full canvas, or the --bbox region when given) are treated as "element
# not present yet" (empty) rather than noise-filled. 0.2% of pixels is below any
# real UI element but above stray anti-aliasing specks.
MIN_AREA_FRAC = 0.002
# Laplacian variance saturates well before pixel max; 1000 is a generous ceiling
# for "perfectly sharp" UI text/edges, used only to normalize blur into [0,1].
SHARPNESS_CEILING = 1000.0
# Default per-pixel intensity-diff cutoff (0-255) for foreground detection.
# Above compression/AA noise, below real element edges. See foreground_mask().
DEFAULT_THRESHOLD = 25
def load_frames(framedir):
names = sorted(f for f in os.listdir(framedir) if f.startswith("frame_") and f.endswith(".png"))
if not names:
sys.exit(f"no frame_*.png in {framedir}; run extract_frames.py first")
frames = []
for n in names:
img = cv2.imread(os.path.join(framedir, n))
if img is None:
sys.exit(f"could not read {n}")
frames.append(img)
return names, frames
def foreground_mask(gray, ref, threshold, invert):
"""Foreground = pixels that differ from the first frame (the resting background).
Works for an element that enters/moves over a static backdrop, which is the
common screen-recording case. Pass --bbox to restrict detection to a region.
"""
diff = cv2.absdiff(gray, ref)
# Fixed intensity-difference threshold. ~25/255 sits above JPEG/video
# compression and anti-aliasing noise (typically <15) yet well below the edge
# contrast of any real UI element, so it isolates the moving element across a
# static backdrop. A statistical threshold (mean+k*std) misfires here: when the
# element covers a large, high-contrast area it pushes the cutoff past 255 and
# detects nothing. Override with --threshold for low-contrast motion.
t = threshold if threshold is not None else DEFAULT_THRESHOLD
_, mask = cv2.threshold(diff, t, 255, cv2.THRESH_BINARY)
if invert:
mask = cv2.bitwise_not(mask)
return mask
def measure(img, mask, min_area):
ys, xs = np.where(mask > 0)
area = xs.size
if area < min_area:
return None # element effectively absent in this frame
x0, x1 = int(xs.min()), int(xs.max())
y0, y1 = int(ys.min()), int(ys.max())
bw, bh = x1 - x0 + 1, y1 - y0 + 1
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
region = gray[y0:y1 + 1, x0:x1 + 1]
# opacity proxy: how much of the bounding box the foreground fills. A fading-in
# element fills less; a solid one fills its box. Normalized by box area.
opacity = float(area) / float(bw * bh)
# blur proxy: Laplacian variance is high for sharp edges, low for blurred.
# Invert + normalize so 1.0 = most blurred, 0.0 = sharp.
sharp = float(cv2.Laplacian(region, cv2.CV_64F).var())
blur = 1.0 - min(sharp / SHARPNESS_CEILING, 1.0)
# radius proxy: fraction of the four bounding-box corners NOT covered by the
# mask. Rounded corners leave the box corners empty; sharp corners fill them.
sub = mask[y0:y1 + 1, x0:x1 + 1]
corner = max(2, min(bw, bh) // 8) # sample an 1/8-edge corner patch, min 2px
corners = [sub[:corner, :corner], sub[:corner, -corner:], sub[-corner:, :corner], sub[-corner:, -corner:]]
filled = sum(c.mean() / 255.0 for c in corners) / 4.0
radius = float(1.0 - filled)
return {
"x": x0, "y": y0, "width": bw, "height": bh,
"cx": (x0 + x1) / 2.0, "cy": (y0 + y1) / 2.0,
"opacity": round(opacity, 4), "blur": round(blur, 4), "radius": round(radius, 4),
}
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("framedir", help="directory of frame_*.png from extract_frames.py")
p.add_argument("--out", default=None, help="output JSON path (default: <framedir>/metrics.json)")
p.add_argument("--bbox", default=None, help="restrict detection to region X,Y,W,H (one run per element)")
p.add_argument("--threshold", type=int, default=None, help="fixed foreground diff threshold (0-255)")
p.add_argument("--invert", action="store_true", help="treat the matched region as background, not foreground")
args = p.parse_args()
names, frames = load_frames(args.framedir)
ref = cv2.cvtColor(frames[0], cv2.COLOR_BGR2GRAY)
clip = None
if args.bbox:
try:
X, Y, W, H = (int(v) for v in args.bbox.split(","))
except ValueError:
sys.exit("--bbox must be X,Y,W,H integers, e.g. --bbox 40,120,300,400")
fh, fw = ref.shape
if X < 0 or Y < 0 or W <= 0 or H <= 0 or X + W > fw or Y + H > fh:
sys.exit(f"--bbox {args.bbox} falls outside the {fw}x{fh} frame")
clip = np.zeros(ref.shape, np.uint8)
clip[Y:Y + H, X:X + W] = 255
# Presence cutoff scales with the searched area: the bbox when given (a small
# element in a small region should still register), else the full canvas.
min_area = MIN_AREA_FRAC * (W * H if clip is not None else ref.size)
timeline = []
for i, (name, img) in enumerate(zip(names, frames)):
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
mask = foreground_mask(gray, ref, args.threshold, args.invert)
if clip is not None:
# Keep only motion inside the bbox so neighboring elements don't
# pollute the measurement; the element is still tracked within it.
mask = cv2.bitwise_and(mask, clip)
m = measure(img, mask, min_area)
timeline.append({"frame": i, "file": name, **(m or {"present": False})})
present = [t for t in timeline if t.get("present") is not False]
if not present:
sys.exit("no element detected in any frame. Try --bbox to name the region, "
"or --threshold to loosen detection, or --invert.")
out = args.out or os.path.join(args.framedir, "metrics.json")
with open(out, "w") as f:
json.dump({"frame_count": len(timeline), "tracked_frames": len(present), "timeline": timeline}, f, indent=2)
print(f"measured {len(present)}/{len(timeline)} frames -> {out}")
if len(present) < len(timeline):
print(f"({len(timeline) - len(present)} frames had no element; likely before entry or after exit)")
if __name__ == "__main__":
main()