Skip to content

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.

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.

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:

<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

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
@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:

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:

@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.

Told when this spec changes

The docs are the contract the components are built against. When one changes, the component changes with it — and the copy in your repo does not. One email when that happens.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.