Skip to content

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.

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. AnimatePresence must live above the condition. If a parent route, tab panel or Suspense boundary 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. Default mode="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.

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

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.