---
title: "Motion catalog"
description: "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)."
canonical: "https://lopifi.com/docs/motion/catalog"
---

# 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).

Source: https://lopifi.com/docs/motion/catalog
Part of: Lopifi — Planned: US GAAP books on autopilot, checked by a CPA (https://lopifi.com)

---

# 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
```

<!-- plate:motion-press -->

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](https://lopifi.com/md/docs/motion/catalog#exits).

### 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 {#value-flash}
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
```

<!-- plate:motion-flash -->

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 {#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
```

<!-- plate:motion-match -->

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 {#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.

```ts
const velocity = Math.abs(delta) / (Date.now() - dragStart);
if (Math.abs(delta) >= 45 || velocity > 0.11) dismiss();
```

<!-- plate:motion-swipe -->

### Boundary damping
Dragging past a natural limit (a sheet already at its top) adds increasing friction instead of a hard stop:
```ts
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.

```ts
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 {#exits}

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](https://lopifi.com/md/docs/motion/catalog#list-insert--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.**

```tsx
<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.** `AnimatePresence` must sit *above* the condition. If a parent route, tab panel or `Suspense` boundary 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 default `mode="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](https://lopifi.com/md/docs/craft/quality-bar#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 {#hold-to-confirm}

For irreversible money movements: wires, ACH past cutoff, bulk deletes.

```css
.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 */
}
```

<!-- plate:motion-hold -->

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](https://lopifi.com/md/docs/catalog/index) 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
```
