Motion catalog
Every animation in Lopifi, specified. Each entry states its frequency tier (which is what licenses it), its purpose (one of the five), and its exact values.
If a component isn't listed here, it doesn't animate.
Tier 0 — Motionless by law
These are seen 100+ times a day. They change state instantly. This list is as important 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. 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, because that's a 12px paint on a tiny surface and it confirms the toggle registered. The rows themselves do not slide, grow, or stagger.
Tier 1 — Feedback (every interaction)
Press
transform: scale(0.97)
100ms --ease-out
Every pressable element: buttons, cards that navigate, list rows that open, chips, icon buttons. This is the single highest-value animation in the system — it makes the interface feel like it's listening.
Scale, not opacity: scale() also scales children, so the icon and label compress with the button, which is what a physical press does.
Hover
background-color / border-color: 140ms --ease-standard
gated: @media (hover: hover) and (pointer: fine)
Never a transform on hover in a data surface. On an interactive card (not a row), a translateY(-1px) lift plus --elevation-1 is permitted — one pixel, and only on cards a user clicks occasionally.
Focus
Instant. No transition on the focus ring. A ring that fades in is a ring that isn't there when a fast keyboard user needs it.
Disabled → enabled
opacity: 140ms --ease-standard
Common in payment forms as validation completes. Opacity only; never animate the color.
Tier 2 — Overlays
Every exit below is a claim about what happens when the element leaves. In React that claim is false 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. Origin-aware is mandatory: a popover that scales from its own center 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). This makes an entire toolbar feel faster without defeating the delay's purpose, and it's a detail almost nothing ships.
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 center — a dialog is not anchored to a trigger. (The one exception to the origin-aware 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 — it implements all of this correctly and there's nothing to improve.
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 rapidly and keyframes restart from zero on interruption. Use Sonner; same reasoning.
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. A toast that expires while the user is in another tab never happened.
Tier 3 — The ambient "life" layer
This is where the interface stops feeling static. All of it is peripheral — nothing here is 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
Why linear: a decaying flash should read as a fading afterimage, and any ease makes it look like a deliberate animation rather than a trace.
This solves the problem count-up animation creates: it draws the eye to a change without ever rendering a false intermediate value. Forty years of trading terminals is the evidence.
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 carrying information, and it's the flash that tells the user 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
transform-origin at the zero baseline, not the bottom — otherwise negative bars grow upward from the floor, which is wrong and briefly shows 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 is seen, not the first time it mounts. These
are two different moments on any page taller than the viewport, and reading them as one
is how a chart ends up with a correct animation nobody ever watches: it plays four hundred
milliseconds while sitting eight hundred pixels below the fold and is fully drawn by the
time the reader scrolls to it. Gate on useInViewOnce — IntersectionObserver,
rootMargin: "0px 0px -15% 0px", disconnected on entry. Held at its start state until
then, so the growth is the first thing the reader sees.
once is not a convenience, it is the rule. A chart that redraws each time it re-enters
the viewport is re-animating data, and re-animation destroys the one thing a reader
scrolling back is trying to do: compare what is 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 carries the change from the previous point — the number a line chart draws and never states. Reading a slope off a line is exactly the estimate a financial interface should not ask anyone to make.
It is revealed, never counted. A figure that ticks from 0 to 5.6% displays a few dozen wrong percentages on the way, and someone glancing mid-animation reads one of them. What animates is the panel arriving; the digits are correct in the first frame.
The panel enters once per hover, not once per point: the element stays mounted as the pointer sweeps and only its contents change, so a pass across twelve months plays one entrance rather than twelve. The change is not coloured — a chart does not know its own polarity (rule 13), and painting every fall red reports a cost coming down as bad news. The arrow and the U+2212 carry the direction, and both survive greyscale (rule 12).
A change is undefined at the first point and wherever the previous value is zero, and
renders as nothing rather than as 0% or ∞. A change from nothing has no percentage, and
inventing one is the same class of error 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, no glow, no ripple — a pulsing ring is a notification pattern 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. Only when there's no progress figure — if you know the count, use 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 is never dimmed: a row at opacity: 0.6 takes its secondary text under AA contrast (3.2:1), which is a reading cost on every pending row to buy one moment of motion. When it confirms, the border goes.
Skeleton
Structural, not shimmering. Grey blocks matching the exact shape and position of the content they replace, --surface-sunken, correct radii, correct heights.
A very slow breathe is permitted: opacity 0.6 ↔ 1, 1.6s ease-in-out infinite. No shimmer sweep — a translating gradient across a full-page skeleton is a large-surface paint animation and it's the tell of a generic template.
Skeletons must match the real layout precisely, or the content lands with a jump. That jump is 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 is what makes it read as one number transforming rather than two numbers swapping. Never a roll, scroll, or odometer.
Tier 4 — The four sanctioned moments
Rare, emotionally loaded, and the only places delight is licensed.
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. Seen a handful of times, high emotional weight, physical object metaphor. Worth every millisecond.
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 lacks momentum — the spring is what makes it feel like a physical object. This is decorative, and it is only acceptable because the card is decorative. The same effect on a KPI card would be 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
Stroke draw via stroke-dasharray/stroke-dashoffset. No confetti, no bounce, no sound. The satisfaction comes from the check being drawn rather than appearing.
Total sequence ≤ 700ms. Any longer and it's between the user and their 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
The most satisfying moment in accounting software, and it happens ~40 times in a close. Bulk match: stagger at 40ms, cap at 240ms, and run the whole sequence once for the group rather than 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 must be enough. Requiring 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) applies increasing friction rather than a hard stop:
const damped = Math.sign(overshoot) * Math.pow(Math.abs(overshoot), 0.7);
Things in the physical world slow before they stop. An invisible wall feels broken.
Pointer capture
setPointerCapture() the moment a drag starts, so it continues when the pointer leaves the element bounds. Without it, a fast drag "drops" the element mid-gesture.
Multi-touch protection
Ignore additional touch points once a drag is underway. Without this, 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 grid-template-rows: 0fr → 1fr, 180ms --ease-out
Card expanding to a panel FLIP + spring.snap
grid-template-rows: 0fr → 1fr is the only correct way to animate to auto height in CSS. It does trigger layout — acceptable on a single small disclosure, forbidden on a list of forty.
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. A finance app's navigation is high-frequency and the content is dense; a page transition delays the data. View Transitions are explicitly out of scope for in-app navigation — they're for navigation-level changes in content-led sites, and they interact badly with interruption.
Exits that actually run
Unmounting a React element removes it from the DOM immediately, and the exit transition never gets a frame. A dialog with a specified 165ms exit that simply vanishes is the most common way this catalog gets quietly ignored — and it reads as 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 do this — they hold the element with data-state="closed" until the transition ends, so a CSS-only exit works and needs nothing else. Reach for 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 cannot drift.
Four ways it breaks, all of them silent:
- 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 live above the condition. If a parent route, tab panel orSuspenseboundary tears it down in the same commit, there is nothing left to run the exit. mode="wait"on a list. It serializes exits: with rapid toast dismissals or a bulk row delete it queues them, and the last one lands seconds late. Defaultmode="sync"for anything that can leave more than one at a time.initial={false}omitted on a list that renders with content. Without it, every row animates in on first paint — which is precisely the everything appearing at once tell in the deslop list.
Reduced motion still unmounts. Set the exit duration to zero; never remove the wrapper. Removing it changes mount timing and produces a second set of bugs in the one configuration nobody tests.
Route level is out of scope — see route transitions above. There is nothing to animate out.
Hold to confirm
For irreversible movements of money — 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 whole design: slow where the user is deciding, fast where the system is responding. 1500ms of deliberate pressure, 200ms to abandon it.
clip-path because it's compositor-friendly and needs no extra DOM. Linear because a progress fill that eases is lying about progress.
Accessibility: hold-to-confirm cannot be the only path. A keyboard user gets Space to start and hold, Escape to abort, with aria-live progress announcements at 0/50/100% — and a typed-confirmation fallback (type SEND to confirm) for anyone who can't hold a key. Never gate a financial action behind a motor-skill requirement.
The site layer
The nine above are the motion a component carries. A documentation site is navigated rather than operated, and the four moments below belong to the shell rather than to anything installed from the registry. Same tokens, same rules, same 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 reads as being
dropped into place, and the reader is watching for the first line of text rather than for
the choreography. It is one CSS rule on main rather than a template.tsx, because a root
template remounts every client component under it — which 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
The pending state is the one that matters, and it is the one nothing had. Clicking a
documentation page with a hundred live demos on it produced no feedback at all until the
route arrived, which is indistinguishable from a dead link. Read from useLinkStatus, so it
cannot drift out of sync with the navigation it reports.
Deliberately not a progress bar at the top of the window: the reader's eye is on the thing they just clicked, and that is where the answer belongs. Vertical navigations get the same three states as a 2px bar in the gutter, which grows in place rather than sliding between entries — a marker travelling thirty rows describes a journey the reader did not take.
Segmented control
Thumb transform: translateX(index × 100%), 180ms --ease-out
Labels do not move, do not fade
Tier 0 says the tab switch itself is instant; the indicator is the sanctioned exception,
because a switch has a position and one object moving is the entire point of the control.
Cells are equal width by construction (grid-auto-columns: 1fr), which buys a
translate-only animation: no width transition, no measurement pass, nothing to re-measure
when a font loads late. First paint places the thumb rather than sliding it in.
Scroll reveal
opacity 0→1, translateY 8px→0, 240ms --ease-out, once
One IntersectionObserver for the whole document, finding [data-reveal]. Sections stay
server-rendered and carry an attribute; a <Reveal> wrapper would make every page that
wanted one 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 arrives gets the page unanimated rather than blank. Content
is never gated on a reveal — under reduced motion everything is revealed immediately and
nothing is observed at all.
Motion spec template
Every component in the catalog carries this block. If a field is empty, the component doesn't animate — and that's 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