---
title: "Motion principles"
description: "Lopifi's motion rules come from Emil Kowalski's animation framework and the performance rules in ui-skills, plus two laws of our own about money."
canonical: "https://lopifi.com/docs/motion/principles"
---

# Motion principles

> Lopifi's motion rules come from Emil Kowalski's animation framework and the performance rules in ui-skills, plus two laws of our own about money.

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

---

# Motion principles

Lopifi's motion rules come from Emil Kowalski's animation framework and the performance rules in `ui-skills`, plus two laws of our own about money.

The default is **no animation**. Every animation in the system had to earn its place.

---

## The decision framework

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

### 1. Should this animate at all?

**Frequency decides.**

| 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, mostly opacity. |
| Occasional — dialogs, sheets, toasts, drawers | Standard animation. |
| Rare / first-time — onboarding, payment success, reconciliation match, card flip | Delight is allowed. |

<!-- plate:motion-frequency -->

In a finance product, the top row covers most of the day. An accountant switches tabs, expands rows and opens filters hundreds of times during a close. **In Lopifi, none of those move.**

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

### 2. What is the purpose?

It has to be one of these five. "It looks nice" isn't on the list.

| Purpose | Example in Lopifi |
|---|---|
| **Spatial consistency** | A sheet enters from the bottom and leaves to the bottom, so swipe-to-dismiss is obvious |
| **State indication** | The swap arrow rotates 180° to show the direction flipped |
| **Explanation** | The reconciliation match slides two rows together to show *what matched with what* |
| **Feedback** | A button scales to 0.97 on press, so you know the interface heard you |
| **Preventing a jarring change** | A toast that appears 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
```

<!-- plate:motion-easing -->

> **Never use `ease-in` on a UI element.** It starts slow, delaying movement exactly when the user is watching. `ease-in` at 300ms *feels* slower than `ease-out` at 300ms. The one legitimate use is an exit that leaves the viewport entirely, and even then `ease-out` is usually better.

**Use custom curves.** CSS's built-in easings are too weak to look intentional. The tokens are in [motion tokens](https://lopifi.com/md/docs/motion/tokens).

### 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 faster with the same load time. Perceived performance is performance.

**Exits are faster than enters.** Enter × 0.7, with a 100ms floor. The user already chose to dismiss; don't make them wait for it.

---

## The two money laws

These don't come from any general motion system. They're 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` shows roughly forty *false* values on the way. A user who glances mid-animation reads a wrong number, and in a financial product that's a material error. Count-ups also break screen readers, `Cmd+F` and copy-paste.

<!-- plate:motion-count-up -->

**When a value changes, do this instead:**

| 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, and animating implies a continuity that isn't there. |
| First paint | No animation on the value. The *container* may fade in. |

The one exception is **a marketing or onboarding surface where the number is illustrative and labeled as an example.** Never 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 the bars grow from zero again after a filter change, the user can't compare the old and new states, and comparing them is why they changed the filter.

```
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
```

Tables follow the same rule: rows don't animate on sort, filter or pagination. They animate on **insert and delete only**, where the motion answers "what changed?".

---

## Interruptibility

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

Transitions retarget from wherever they are. Keyframes restart from zero. With toasts arriving quickly, a row hover flickering under a moving cursor or a sheet flicked mid-animation, transitions stay smooth and keyframes stutter.

<!-- plate:motion-interrupt -->

Springs go further and preserve **velocity** through an interruption. So anything a finger can grab (sheets, drawers, swipe-to-dismiss, drag-to-reorder) uses a spring, not a duration.

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

---

## Performance rules

These come from `ui-skills/fixing-motion-performance`, and they're absolute.

### Never patterns
- **Never interleave layout reads and writes in a frame.** Batch the reads, then the 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 run 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.

<!-- plate:motion-compositor -->

Paint properties (`background`, `color`, `border-color`) are allowed **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 has to look like it's changing size, use **FLIP**: measure the first position, apply the final state, measure the 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](https://lopifi.com/md/docs/foundations/elevation-borders-shadows#backdrops).

### `will-change`
Add it when an interaction *starts* and 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 through `requestAnimationFrame` and drop frames when the browser is busy, which it always is during a data-heavy page load. For anything that has to 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 loads. Keep JS animation for what's truly dynamic and interruptible.

### Off-screen
Pause every looping animation when it's off-screen (`IntersectionObserver`) and when the tab is hidden (`document.visibilityState`). That covers presence pulses, indeterminate bars and live tickers. Pause toast auto-dismiss timers on tab hide too, or the toast expires before the user ever sees it.

---

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

When a crossfade looks wrong no matter which easing and duration you try, it's because you're seeing **two objects** overlap mid-fade. A `filter: blur(2px)` at the midpoint blends them, so the eye reads one thing changing instead of two things swapping.

<!-- plate:motion-blur-mask -->

Use it for a changed monetary value (Law 1), a button label changing between states, a status pill changing state or an icon swap.

Keep it under 20px. Heavy blur is expensive, especially in Safari. All of ours are 2px.

---

## Reduced motion

`prefers-reduced-motion: reduce` means **fewer and gentler** animations, 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 carries information) | 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; }
}
```

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

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

---

## Touch and hover

Touch devices fire `:hover` on tap, which leaves hover animations stuck after a tap. Gate every hover effect:

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

Every hover-revealed affordance also 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 overlapping in a crossfade, the wrong `transform-origin`, properties finishing at different times and abrupt starts.
- **Step frame by frame** in the DevTools Animations panel to catch coordination bugs you can't see at speed.
- **Test on real hardware.** Gesture feel doesn't survive a simulator. Try the sheet on an actual phone, over the network, with the CPU busy.
- **Throttle to 4× slowdown** and check that nothing drops frames. If something does, it isn't compositor-only.
- **Look at it the next day.** Fresh eyes catch flaws you couldn't see while building it.
