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. |
Frequency decides
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
Equal time, unequal distance
Never use
ease-inon a UI element. It starts slow, delaying movement exactly when the user is watching.ease-inat 300ms feels slower thanease-outat 300ms. The one legitimate use is an exit that leaves the viewport entirely, and even thenease-outis usually better.
Use custom curves. CSS's built-in easings are too weak to look intentional. The tokens are 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 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.
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.
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). Usetransform. - Never drive animation from
scrollevents orscrollY. UseIntersectionObserverfor visibility and scroll/view timelines for scroll-linked motion. - Never run 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.
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-filteron a full-screen scrim is forbidden; see elevation.
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:
<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 #
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.
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 |
@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:
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:
@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.