Motion catalog
Every animation in Lopifi, with exact values. Each entry gives its frequency tier (which decides whether it may animate at all) and its purpose (one of the five).
If a component isn't listed here, it doesn't animate.
Tier 0 — Motionless by law#
Users see these 100+ times a day, so they change state instantly. This list matters as much as everything below it.
| Surface | What happens instead |
|---|---|
| Command palette open/close | Instant. No fade, no scale. |
Any Cmd+K / keyboard-shortcut action |
Instant. |
| Tab switch | Instant content swap. The indicator may slide (see below). |
| Table row hover | background-color only, 100ms. No transform, no shadow, no lift. |
| Table sort | Instant re-order. No FLIP, no stagger. |
| Filter apply | Instant. |
| Pagination | Instant. |
| Expand/collapse a statement row | Instant. It happens hundreds of times per close; see below for the one concession. |
| Checkbox / radio / switch toggle | Instant state; the switch thumb may translate 100ms. |
| Focus move (Tab) | Instant ring. |
| Number value change | Never interpolated. Ever. (Money Law 1) |
The statement-row concession: expanding a P&L row is high-frequency, so the content appears instantly. The chevron rotates 90° over 140ms. It's a 12px paint on a tiny surface, and it confirms the toggle registered. The rows themselves don't slide, grow or stagger.
Tier 1 — Feedback (every interaction)#
Press#
transform: scale(0.97)
100ms --ease-out
Every pressable element gets it: buttons, cards that navigate, list rows that open, chips, icon buttons. It's the most valuable animation in the system because it tells the user the interface is listening.
Scale, not opacity. scale() also scales children, so the icon and label compress with the button the way a physical press would.
Hover#
background-color / border-color: 140ms --ease-standard
gated: @media (hover: hover) and (pointer: fine)
No transform on hover in a data surface. An interactive card (not a row) may lift with translateY(-1px) plus --elevation-1. One pixel, and only on cards a user clicks occasionally.
Focus#
Instant. The focus ring has no transition, because a ring that fades in isn't there when a fast keyboard user needs it.
Disabled → enabled#
opacity: 140ms --ease-standard
Common in payment forms once validation passes. Opacity only; never animate the color.
Tier 2 — Overlays#
Every exit below describes what happens when the element leaves. In React, none of them run by default; see exits that actually run.
Dropdown / menu / select / combobox#
enter opacity 0→1, scale 0.97→1 180ms --ease-out
exit opacity 1→0, scale 1→0.97 125ms --ease-out
origin var(--transform-origin) ← from the trigger
Popover#
Same as dropdown. It must scale from its trigger, or it looks detached from the button that opened it.
Tooltip#
enter opacity 0→1, scale 0.97→1 140ms --ease-out
exit opacity 1→0 100ms
delay 400ms before the first one
Skip the delay and the animation on subsequent tooltips. Once one tooltip in a group is open, hovering a neighbor opens it instantly (data-instant → transition-duration: 0ms). The whole toolbar feels faster, and the delay still does its job.
Dialog#
scrim opacity 0→1 240ms --ease-out
dialog opacity 0→1, scale 0.97→1 240ms --ease-out, origin center
exit opacity, scale→0.98 165ms
Origin is center because a dialog isn't anchored to a trigger. It's the one exception to the scale-from-trigger rule.
Sheet / drawer (mobile primary)#
enter translateY(100%) → 0 320ms --ease-drawer
exit 0 → translateY(100%) 225ms --ease-drawer
drag tracks the finger 1:1, no duration
release spring.gesture { duration: 0.5, bounce: 0.18 }
Full gesture spec below. Use Vaul, which already gets all of this right.
Toast#
enter translateY(100%) → 0, opacity 0→1 400ms ease (CSS transition, not keyframes)
exit translateY(100%), opacity 1→0 200ms
stack each toast behind: scale −0.05, translateY −16px, opacity −0.2
swipe tracks the finger; dismiss at 45px OR velocity > 0.11 px/ms
Transitions, not keyframes: toasts arrive quickly, and keyframes restart from zero when interrupted. Use Sonner, which already does this.
Enter and exit from the same edge, always, so swipe-to-dismiss is spatially obvious.
Pause the auto-dismiss timer when the tab is hidden. Otherwise the toast expires while the user is in another tab and they never see it.
Tier 3 — The ambient "life" layer#
This layer keeps the interface from feeling static. All of it is peripheral; nothing here sits in the path of a task.
The value flash — the tick #
The most important animation in a live financial interface.
When a displayed value updates from live data
What the CELL BACKGROUND flashes; the digits swap instantly
Up background: --positive-flash → transparent
Down background: --negative-flash → transparent
Timing 640ms linear
It's linear because a decaying flash should look like a fading afterimage. Any easing makes it look like a deliberate animation.
It draws the eye to a change without ever rendering a false intermediate value, which is what count-up animations get wrong. Trading terminals have worked this way for forty years.
Rules:
- Never on first paint. Only on update.
- Never on a filter/period change. The whole dataset changed, so flashing every cell is noise.
- Throttle: max one flash per cell per 400ms. A cell updating at 10Hz should flash 2–3 times a second, not 10.
- Under reduced motion, keep it. It's a color change that carries information: the flash is how the user knows something moved.
- Respect
--data-value-palette="signal". The flash uses the same blue/amber mapping.
Sparkline draw-in#
When first paint, or scrolling into view (IntersectionObserver, { once: true })
What stroke-dashoffset: length → 0
Timing 600ms --ease-out
One-shot. Never on data change (Money Law 2). The area fill, if present, fades in over the last 200ms so the line leads and the fill follows.
Chart draw-in#
Line/area stroke-dashoffset, 400ms --ease-out
Bar scaleY 0→1, transform-origin at the ZERO BASELINE, 400ms, stagger 30ms (cap 240ms)
Donut stroke-dashoffset sweep, 400ms
Put transform-origin at the zero baseline, not the bottom. Otherwise negative bars grow upward from the floor and briefly show a value with the wrong sign.
First paint only. On data change: instant, or a 120ms opacity crossfade.
"First paint" means the first time it's seen, not the first time it mounts. On any
page taller than the viewport those are different moments. Treat them as one and the chart
plays its 400ms animation eight hundred pixels below the fold, fully drawn before anyone
scrolls to it. Gate on useInViewOnce: IntersectionObserver,
rootMargin: "0px 0px -15% 0px", disconnected on entry. The chart holds its start state
until then, so the growth is the first thing the reader sees.
once is the rule. A chart that redraws every time it re-enters the viewport is
re-animating data, which breaks what a reader scrolling back is trying to do: compare what's
on screen now with what was there before.
Chart hover readout#
Crosshair + point transform: translate(), 100ms --ease-out (glides between points)
Readout panel ledger-enter — opacity + 8px, 240ms --ease-out, on hover start ONLY
Value replaced instantly. Never interpolated. (Money Law 1)
Change revealed with the panel, not counted up
The readout shows the change from the previous point, the number a line chart draws but never states. A financial interface shouldn't make anyone estimate a slope by eye.
It's revealed, never counted. A figure that ticks from 0 to 5.6% shows a few dozen wrong percentages on the way, and someone glancing mid-animation reads one of them. Only the panel animates; the digits are correct in the first frame.
The panel enters once per hover, not once per point. It stays mounted as the pointer sweeps and only its contents change, so a pass across twelve months plays one entrance, not twelve. The change is not colored. A chart doesn't know its own polarity (rule 13), and painting every fall red reports a falling cost as bad news. The arrow and the U+2212 carry the direction, and both survive grayscale (rule 12).
A change is undefined at the first point and wherever the previous value is zero, and it
renders as nothing, not as 0% or ∞. A change from nothing has no percentage. Inventing
one is the same mistake as rendering a null balance as $0.00 (rule 10).
Presence / sync indicator#
2s, opacity 0.4 ↔ 1, --ease-standard, infinite
Pauses off-screen (IntersectionObserver) and on tab hide
Opacity only. No scale, glow or ripple: a pulsing ring means notification, and this is a status.
Indeterminate sync bar#
A 2px hairline at the top of a syncing card. translateX(-100% → 100%) on a 40%-width segment, 1.2s linear, infinite. Pauses off-screen. Use it only when there's no progress figure. If you know the count, show a determinate bar and a number.
Optimistic / pending row#
No animation. A pending row renders at full opacity with a 2px --warning left border and its pending status. It's never dimmed: at opacity: 0.6 its secondary text drops below AA contrast (3.2:1). When the row confirms, the border goes.
Skeleton#
Structural, not shimmering. Gray --surface-sunken blocks that match the exact shape, position, radii and height of the content they replace.
A very slow breathe is allowed: opacity 0.6 ↔ 1, 1.6s ease-in-out infinite. No shimmer sweep. A gradient sliding across a full-page skeleton is a large-surface paint animation, and it marks a generic template.
A skeleton that doesn't match the real layout makes the content jump when it lands, and that's worse than no skeleton.
Scroll shadow#
opacity 0→1 on the sticky header's shadow, 140ms --ease-standard
driven by IntersectionObserver on a 1px sentinel — never a scroll listener
Number crossfade#
When a value changes as a result of a user action (not live data):
old: opacity 1→0, filter blur(0→2px) 140ms
new: opacity 0→1, filter blur(2→0px) 140ms, starting at 70ms
The blur mask makes it read as one number changing instead of two numbers swapping. Never a roll, scroll or odometer.
Tier 4 — The four sanctioned moments#
These are rare and emotionally loaded, and they're the only places delight is allowed.
1. The card flip #
PaymentMethodCard revealing the CVV.
container transform-style: preserve-3d; perspective: 1200px
flip rotateY(0 → 180deg), spring.playful { duration: 0.45, bounce: 0.22 }
faces backface-visibility: hidden; back face pre-rotated 180deg
Real 3D, not a crossfade. Users see it a handful of times, it matters to them, and it mimics a physical card, so it earns the time.
Under reduced motion: instant swap, no rotation.
2. The pointer tilt#
PaymentMethodCard on hover, desktop only.
rotateX / rotateY ±6deg max, from pointer position
spring.track { stiffness: 150, damping: 20, mass: 0.6 }
specular a linear-gradient sheen tracking the pointer, opacity 0→0.12
gated @media (hover: hover) and (pointer: fine)
reset springs home on pointer leave
Tying rotation directly to pointer position feels artificial because it has no momentum. The spring makes it feel like a physical object. The effect is decorative, and it's acceptable only because the card is decorative. The same effect on a KPI card is forbidden.
3. The payment success#
1. Button label crossfades to a spinner 140ms
2. On success: circle scales 0.9→1 240ms --ease-out
3. Checkmark stroke draws 320ms --ease-out, 80ms after the circle
4. The amount and destination fade up 8px 240ms --ease-out, staggered 40ms
The stroke draws via stroke-dasharray/stroke-dashoffset. No confetti, bounce or sound. The satisfaction comes from watching the check get drawn.
The whole sequence is ≤ 700ms. Any longer and it gets in the way of the user's next task.
4. The reconciliation match#
1. Both rows highlight --accent-subtle 140ms
2. FLIP: they translate toward each other 320ms --ease-in-out
3. Merge: one fades out, the other gains a check 200ms
4. The merged row settles into the matched list spring.snap
It's the most satisfying moment in accounting software, and it happens ~40 times in a close. For a bulk match, stagger at 40ms, cap at 240ms, and run the sequence once for the group instead of per pair.
Gestures #
Swipe to dismiss (toasts, sheets, mobile rows)#
Track the finger 1:1 with transform: translate()
Dismiss when: distance > 45px OR velocity > 0.11 px/ms
Return when neither: spring.gesture
Velocity, not just distance. A quick flick has to be enough. Demanding a long drag when the user clearly meant to dismiss is the most common gesture failure.
const velocity = Math.abs(delta) / (Date.now() - dragStart);
if (Math.abs(delta) >= 45 || velocity > 0.11) dismiss();
Boundary damping#
Dragging past a natural limit (a sheet already at its top) adds increasing friction instead of a hard stop:
const damped = Math.sign(overshoot) * Math.pow(Math.abs(overshoot), 0.7);
Physical things slow down before they stop. An invisible wall feels broken.
Pointer capture#
Call setPointerCapture() as soon as a drag starts so it continues when the pointer leaves the element. Without it, a fast drag "drops" the element mid-gesture.
Multi-touch protection#
Ignore extra touch points once a drag is underway. Otherwise, switching fingers mid-drag teleports the element to the new position.
function onPointerDown(e) { if (isDragging) return; /* … */ }
Never set a CSS variable per frame#
Setting --swipe-amount on a container recalculates styles for every descendant. Set transform directly on the moving element.
Drag to reorder (table columns, dashboard widgets)#
lift scale 1.02, --elevation-4, 140ms
track 1:1, transform only
others FLIP into their new positions, spring.snap
drop spring.snap to final
Measure once at drag start. Never read layout during the drag.
Layout transitions#
Expand / collapse#
Statement rows instant (Tier 0). Chevron rotates 90°, 140ms.
Accordion / disclosure opens at once; content fades in, opacity 0→1, 180ms --ease-out
Card expanding to a panel FLIP + spring.snap
Don't animate height or grid-template-rows to reach auto height. Both trigger layout on every frame. Open the panel at full size immediately and fade the content in, which reads as smooth and costs nothing.
List insert / delete#
insert opacity 0→1, translateY(-8px)→0, 240ms --ease-out
delete opacity 1→0, 165ms; then siblings FLIP into place, spring.snap
Insert and delete only. Not sort, not filter, not paginate.
Route transitions#
None. Navigation in a finance app is high-frequency and the content is dense, so a page transition just delays the data. View Transitions are out of scope for in-app navigation. They suit content-led sites and handle interruption badly.
Exits that actually run #
Unmounting a React element removes it from the DOM immediately, so the exit transition never gets a frame. A dialog specced with a 165ms exit that just vanishes is the most common way this catalog gets quietly ignored, and it looks like a bad spec rather than a bug. Adapted from raphaelsalaja/mastering-animate-presence.
The rule: everything in Tier 2, plus list delete, must stay mounted through its exit.
Base UI and Radix already handle this. They hold the element with data-state="closed" until the transition ends, so a CSS-only exit just works. Use AnimatePresence (motion/react) only for elements you mount yourself.
<AnimatePresence initial={false}>
{open && (
<motion.div
key="dialog"
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.165, ease: easeOut }}
/>
)}
</AnimatePresence>
easeOut is --ease-out as a coefficient array: [0.23, 1, 0.32, 1]. JS animation can't read a CSS variable, so the curve exists twice. Keep both in the token file so they can't drift.
It breaks silently in four ways:
- A missing or index-derived
key. React reuses the node, and the exit is skipped for the item that actually left. Key on the row's stable id, never its position. - The wrapper unmounting with its child.
AnimatePresencemust sit above the condition. If a parent route, tab panel orSuspenseboundary tears it down in the same commit, nothing is left to run the exit. mode="wait"on a list. It serializes exits, so rapid toast dismissals or a bulk row delete queue up and the last one lands seconds late. Use the defaultmode="sync"for anything where more than one item can leave at once.initial={false}omitted on a list that renders with content. Without it, every row animates in on first paint, which is the everything appearing at once tell in the deslop list.
Reduced motion still unmounts. Set the exit duration to zero, but keep the wrapper. Removing it changes mount timing and creates a second set of bugs in the configuration nobody tests.
Routes are out of scope (see route transitions above). There's nothing to animate out.
Hold to confirm #
For irreversible money movements: wires, ACH past cutoff, bulk deletes.
.hold-btn { transition: transform 100ms var(--ease-out); }
.hold-btn:active { transform: scale(0.97); }
.hold-fill {
clip-path: inset(0 100% 0 0);
transition: clip-path 200ms var(--ease-out); /* release: FAST */
}
.hold-btn:active .hold-fill {
clip-path: inset(0 0 0 0);
transition: clip-path 1500ms var(--ease-linear); /* press: SLOW */
}
The asymmetry is the design: slow where the user is deciding, fast where the system is responding. 1500ms of deliberate pressure, 200ms to back out.
clip-path is compositor-friendly and needs no extra DOM. The fill is linear because a progress fill that eases misrepresents progress.
Accessibility: hold-to-confirm can't be the only path. Keyboard users hold Space to confirm and press Escape to abort, with aria-live progress announcements at 0/50/100%. Anyone who can't hold a key gets a typed-confirmation fallback (type SEND to confirm). Never gate a financial action on motor skill.
The site layer#
Everything above is motion a component carries. The four moments below belong to the documentation site's shell, not to anything installed from the registry. They use the same tokens, rules and reduced-motion switch.
Route change#
main { animation: ledger-page-enter 180ms --ease-out both }
opacity 0→1, translateY 4px→0
4px and 180ms, not the components' 8px and 240ms. A whole page moving 8px looks dropped
into place, and the reader is looking for the first line of text, not the choreography.
It's one CSS rule on main instead of a template.tsx, because a root template remounts
every client component under it and would reset the showcase's theme, palette and density
toggles on every navigation. The one shell that keeps its main across routes applies the
same keyframe to a key={pathname} wrapper instead.
The keyframe ends at transform: none, not at a zero translate, so a position: sticky
descendant has no transformed ancestor once it settles.
Navigation state#
Rule under a nav item scaleX 0→1 from the left, 180ms --ease-out
Active drawn, --accent, full opacity
Hover / focus-visible drawn at 45%
Pending full width, --text-tertiary, ledger-breathe 900ms
Pending is the state that matters, and it was missing. Clicking a docs page with a
hundred live demos gave no feedback until the route arrived, which looks exactly like a dead
link. It reads from useLinkStatus, so it can't drift out of sync with the navigation it
reports.
It's deliberately not a progress bar at the top of the window. The reader is looking at what they just clicked, so the feedback goes there. Vertical navigations get the same three states as a 2px bar in the gutter that grows in place instead of sliding between entries. A marker traveling thirty rows describes a journey the reader didn't take.
Segmented control#
Thumb transform: translateX(index × 100%), 180ms --ease-out
Labels do not move, do not fade
Tier 0 makes the tab switch instant. The indicator is the exception, because a switch
has a position and one object moving is the point of the control. Cells are equal width by
construction (grid-auto-columns: 1fr), so the animation is translate-only: no width
transition, no measurement pass, nothing to re-measure when a font loads late. On first
paint the thumb is placed, not slid in.
Scroll reveal#
opacity 0→1, translateY 8px→0, 240ms --ease-out, once
One IntersectionObserver for the whole document watches [data-reveal]. Sections stay
server-rendered and carry an attribute. A <Reveal> wrapper would turn every page that
used it into a client component for the sake of eight pixels.
The hidden state is gated on an attribute the observer sets on <html> after it mounts, so
a reader whose JavaScript never loads gets the page unanimated instead of blank. Content is
never gated on a reveal. Under reduced motion everything shows immediately and nothing is
observed.
Motion spec template#
Every component in the catalog carries this block. An empty field means the component doesn't animate, which is the expected answer.
Motion
Frequency tier 0 | 1 | 2 | 3 | 4
Purpose spatial | state | explanation | feedback | anti-jarring
Enter <property> <from>→<to>, <duration> <easing>, origin <x>
Exit …
Gesture …
Reduced motion what is kept, what is removed
Perf note why this is compositor-only, or why the exception is safe