# Motion principles

> Lopifi's motion doctrine is Emil Kowalski's animation framework, plus the performance rules from ui-skills, plus two laws specific to money.

Source: https://lopifi.com/docs/motion/01-principles
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

# Motion principles

Lopifi's motion doctrine is Emil Kowalski's animation framework, plus the performance rules from `ui-skills`, plus two laws specific to money.

The default is **no animation**. Every animation in this system had to argue its way in.

---

## The decision framework

Answer in order. Stop at the first "no".

### 1. Should this animate at all?

**Frequency governs everything.**

| How often the user sees it | Decision |
|---|---|
| 100+ times/day — keyboard shortcuts, command palette, row hover, tab switch | **No animation. Ever.** |
| Tens of times/day — dropdowns, filters, tooltips, expanding a row | Minimal. ≤ 160ms, opacity-dominant. |
| Occasional — dialogs, sheets, toasts, drawers | Standard animation. |
| Rare / first-time — onboarding, payment success, reconciliation match, card flip | Delight is permitted. |

In a finance product the top row is enormous. An accountant switches tabs, expands rows and opens filters hundreds of times in a close. **Every one of those is motionless in Lopifi.**

> **Never animate a keyboard-initiated action.** Raycast has no open/close animation and that's optimal. Our command palette opens instantly. So does `Cmd+K`, `Esc`, arrow-key navigation, and every shortcut in the product.

### 2. What is the purpose?

Must be one of these five. "It looks nice" is not on the list.

| Purpose | Example in Lopifi |
|---|---|
| **Spatial consistency** | A sheet enters from the bottom and leaves to the bottom, so swipe-to-dismiss feels obvious |
| **State indication** | The swap arrow rotates 180° to show the direction inverted |
| **Explanation** | The reconciliation match animates two rows together, showing *what matched with what* |
| **Feedback** | A button scales to 0.97 on press, confirming the interface heard you |
| **Preventing a jarring change** | A toast appearing instantly with no transition reads as a glitch |

### 3. What easing?

```
Entering or exiting?          → ease-out          (fast start = responsive)
Moving/morphing on screen?    → ease-in-out       (natural accel/decel)
Hover or color change?        → ease              (symmetric, unremarkable)
Constant motion?              → linear            (progress, marquee, hold)
Default                       → ease-out
```

> **Never `ease-in` on a UI element.** It starts slow, which delays movement at the exact moment the user is watching most closely. `ease-in` at 300ms *feels* slower than `ease-out` at 300ms. The only legitimate use is an exit that's leaving the viewport entirely, and even then `ease-out` is usually better.

**Use custom curves.** CSS's built-in easings are too weak to read as intentional. Tokens in [motion tokens](02-tokens.md).

### 4. How fast?

| Element | Duration |
|---|---|
| Button press feedback | 100–160ms |
| Tooltip, small popover | 125–200ms |
| Dropdown, select, menu | 150–250ms |
| Modal, drawer, sheet | 200–500ms |
| Explanatory / marketing | Longer is fine |

> **UI animation stays under 300ms.** A 180ms dropdown feels more responsive than a 400ms one. A faster spinner makes an app feel like it loads faster with identical load times. Perceived performance *is* performance.

**Exit is faster than enter.** Enter × 0.7, floor 100ms. The user has already decided to dismiss; don't make them wait for the decision they made.

---

## The two money laws

Not from any general motion system. These are ours.

### Law 1 — Never animate a value the user might read as truth

> **A number does not count up, roll, tick through intermediate values, or interpolate.**

A balance animating from `$0` to `$248,190.24` renders roughly forty *false* values on its way. It's charming exactly once and dangerous forever — a user who glances mid-animation reads a wrong number, and in a financial product that's a material error, not a cosmetic one. It also breaks screen readers, `Cmd+F`, and copy-paste.

**What to do instead when a value changes:**

| Situation | Treatment |
|---|---|
| Live update (streaming balance, rate tick) | **The Bloomberg flash** — the cell *background* flashes `--positive-flash` / `--negative-flash` and decays to transparent over 640ms `linear`. The digits swap instantly. |
| Value changed after a user action | **Crossfade**, 140ms, with `filter: blur(2px)` at the midpoint to mask the swap. Old value out, new value in. Never a scroll or roll. |
| Value changed on filter/period change | No animation. The whole dataset changed; animating implies continuity that isn't there. |
| First paint | No animation on the value. The *container* may fade in. |

The single exception: **a marketing or onboarding surface, where the number is illustrative and labeled as an example.** Not in the product.

### Law 2 — Data does not re-animate

> **A chart animates on first paint. It does not animate when the data changes.**

If a user changes a filter and the bars grow from zero again, they cannot compare the old and new states — and comparison is the entire reason they changed the filter. Re-animation actively destroys the task.

```
First mount            → draw in (once), 400ms, ease-out, stagger 30ms
Data change            → instant swap, or a 120ms opacity crossfade
Scroll into view       → draw in once (IntersectionObserver, { once: true })
Resize                 → instant
```

Same rule for tables: rows don't animate on sort, filter, or pagination. They animate on **insert and delete only**, because there the motion answers "what changed?".

---

## Interruptibility

> **Use CSS transitions, not keyframes, for anything that can be re-triggered.**

Transitions retarget from their current position. Keyframes restart from zero. For toasts arriving rapidly, a row hover flickering under a moving cursor, or a sheet flicked mid-animation, transitions are smoother and keyframes visibly stutter.

Springs go further: they preserve **velocity** across interruption. That's why anything a finger can grab — sheets, drawers, swipe-to-dismiss, drag-to-reorder — uses a spring, not a duration.

Use keyframes only for genuinely one-shot, non-interruptible things: a checkmark stroke draw, a skeleton pulse, an indeterminate progress bar.

---

## Performance rules

From `ui-skills/fixing-motion-performance`, and they're absolute.

### Never patterns
- **Never interleave layout reads and writes in a frame.** Batch all reads, then all writes.
- **Never animate layout properties** — `width`, `height`, `top`, `left`, `margin`, `padding`. Use `transform`.
- **Never drive animation from `scroll` events or `scrollY`.** Use `IntersectionObserver` for visibility, and scroll/view timelines for scroll-linked motion.
- **Never a `requestAnimationFrame` loop without a stop condition.**
- **Never mix animation systems** that each measure or mutate layout on the same element.

### Compositor only
Animate `transform` and `opacity`. They skip layout and paint entirely.

Paint properties (`background`, `color`, `border-color`) are permitted **only on small, isolated surfaces** — a 14px label's color, a 40px row's background, a button's border. Never on a card, a table, or a full-width container.

### Layout-like effects
When something must appear to change size, use **FLIP**: measure first position, apply the final state, measure last position, apply an inverting `transform`, then transition it to zero. One read batch, one write batch, compositor-only animation.

### Blur
- ≤ 8px, always.
- One-shot only. **Never animate blur continuously.**
- Never on a large surface. `backdrop-filter` on a full-screen scrim is forbidden — see [elevation](../foundations/04-elevation-borders-shadows.md#backdrops).

### `will-change`
Add it when an interaction *starts*, remove it when it ends. A permanent `will-change` holds a compositor layer forever and costs memory on every scroll. Never set it in a static stylesheet.

### CSS variables
**Never animate an inherited CSS variable.** Changing `--swipe-amount` on a container recalculates styles for every descendant — in a drawer with forty rows that's a visible stall. Set `transform` directly on the element that moves.

### Motion library caveat
Motion's shorthand props (`x`, `y`, `scale`) run on the main thread via `requestAnimationFrame` and drop frames when the browser is busy — which, during a data-heavy page load, it always is. For anything that must stay smooth under load, use the full transform string:

```tsx
<motion.div animate={{ transform: "translateX(100px)" }} />   // composited
<motion.div animate={{ x: 100 }} />                            // main thread
```

Better still: **use CSS for anything predetermined.** CSS animations run off the main thread and stay smooth while the page is loading. Reserve JS animation for what's genuinely dynamic and interruptible.

### Off-screen
Pause every looping animation when off-screen (`IntersectionObserver`) and when the tab is hidden (`document.visibilityState`). Applies to presence pulses, indeterminate bars, and live tickers. Also pause toast auto-dismiss timers on tab hide — a toast that expires while the user is in another tab never existed.

---

## Blur as a transition mask {#blur-as-a-transition-mask}

When a crossfade between two states looks wrong despite trying every easing and duration, the problem is that you see **two distinct objects** overlapping mid-fade. A `filter: blur(2px)` at the midpoint blends them, and the eye reads one thing transforming rather than two things swapping.

Use it for: a changed monetary value (Law 1), a button's label morphing between states, a status pill changing state, an icon swapping.

Keep it under 20px — heavy blur is expensive, especially in Safari. Our uses are all 2px.

---

## Reduced motion

`prefers-reduced-motion: reduce` means **fewer and gentler**, not zero.

| Keep | Remove |
|---|---|
| Opacity transitions | Translation and movement |
| Color transitions | Scale |
| Instant state changes | Springs and bounce |
| The value flash (it's a color change, and it's informational) | Parallax, stagger |
| Focus rings | 3D rotation, the card flip |

```css
@media (prefers-reduced-motion: reduce) {
  *, ::before, ::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  /* then selectively restore informational transitions */
  [data-motion-safe="opacity"] { transition-duration: 160ms !important; }
}
```

The nuance the blanket reset misses: an element that *appears* with no transition at all can be more disorienting than one that fades. So the reset kills movement, and we opt specific opacity transitions back in.

In React, `useReducedMotion()` and branch the values, don't branch the component:
```tsx
const reduce = useReducedMotion();
const enterY = reduce ? 0 : 8;
```

---

## Touch and hover

Touch devices fire `:hover` on tap, so hover animations produce a stuck state after a tap. Gate every hover effect:

```css
@media (hover: hover) and (pointer: fine) {
  .row:hover { background: var(--surface-hover); }
}
```

And every hover-revealed affordance needs a non-hover equivalent — on touch, row actions live in a swipe or an overflow menu, never behind hover alone.

---

## Reviewing motion

- **Slow it down.** Run at 4× duration and look for: two states visibly overlapping in a crossfade, wrong `transform-origin`, properties finishing at different times, abrupt starts.
- **Step frame by frame** in the DevTools Animations panel to catch coordination bugs invisible at speed.
- **Test on real hardware.** Gesture feel does not survive a simulator. Test the sheet on an actual phone, over the network, with the CPU busy.
- **Throttle to 4× slowdown** and check that nothing jank-drops. If it does, it isn't compositor-only.
- **Look at it the next day.** You will see imperfections with fresh eyes that were invisible while you were building it.
