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-inon a UI element. It starts slow, which delays movement at the exact moment the user is watching most closely.ease-inat 300ms feels slower thanease-outat 300ms. The only legitimate use is an exit that's leaving the viewport entirely, and even thenease-outis 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. Usetransform. - Never drive animation from
scrollevents orscrollY. UseIntersectionObserverfor visibility, and scroll/view timelines for scroll-linked motion. - Never a
requestAnimationFrameloop 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-filteron 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.