---
title: "Motion tokens"
description: "Every animation in Lopifi uses a token. An ad-hoc duration or inline cubic-bézier fails review; see quality bar."
canonical: "https://lopifi.com/docs/motion/tokens"
---

# Motion tokens

> Every animation in Lopifi uses a token. An ad-hoc duration or inline cubic-bézier fails review; see quality bar.

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

---

# Motion tokens

Every animation in Lopifi uses a token. An ad-hoc duration or inline cubic-bézier fails review; see [quality bar](https://lopifi.com/md/docs/craft/quality-bar).

---

## Easing

```css
/* Entrances, exits, and the system default.
   Strong ease-out — quintic. Punchy start, long settle. */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);

/* Movement and morphing on screen. Symmetric, strong. */
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);

/* Sheets and drawers. The Ionic/iOS curve — the one that makes a
   drawer feel like it has weight. */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);

/* Hover, color, small unremarkable state changes. */
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);

/* Constant motion: progress, hold-to-confirm, marquee, value flash decay. */
--ease-linear: linear;
```

<!-- plate:motion-curves -->

**There is no `--ease-in`.** It's left out of the token file on purpose so nobody reaches for it. If you need a curve that isn't here, raise it as a design question, not a CSS change.

The built-in CSS keywords (`ease`, `ease-out`, `ease-in-out`) are too weak to look intentional. `cubic-bezier(0.23, 1, 0.32, 1)` covers about two-thirds of the distance in the first 20% of the time, so the interface feels like it responded instead of playing an animation.

---

## Duration

```css
--duration-0:     0ms;     /* keyboard actions, high-frequency interactions */
--duration-1:   100ms;     /* press feedback, instant hover */
--duration-2:   140ms;     /* hover, tooltip, color, small crossfade */
--duration-3:   180ms;     /* dropdown, popover, menu, expand */
--duration-4:   240ms;     /* dialog enter, toast enter */
--duration-5:   320ms;     /* sheet, drawer, page-level transition */
--duration-6:   400ms;     /* chart draw-in (first paint only) */
--duration-flash: 640ms;   /* live value tick decay */
--duration-hold: 1500ms;   /* hold-to-confirm */
```

<!-- plate:motion-durations -->

### Exit durations
**Exit = enter × 0.7, floored at 100ms.** It's computed rather than a separate token set, and each component's spec records it:

| Enter | Exit |
|---|---|
| 140 | 100 |
| 180 | 125 |
| 240 | 165 |
| 320 | 225 |

On enter, the user is discovering the element and the motion shows where it came from. On exit, they've already decided and are waiting on you.

### The general asymmetry principle
**Slow where the user is deciding, fast where the system is responding.** Hold-to-confirm presses over 1500ms `linear` and releases in 200ms `ease-out`. A drag has no duration at all (it tracks the finger); the release springs home fast.

---

## Springs

Springs are for anything a pointer can grab. We use Apple's `duration`/`bounce` parameters: they're easier to reason about than mass/stiffness/damping and map directly onto Motion's spring API.

```ts
export const spring = {
  /** Default. No overshoot. Sheets, panels, layout shifts. */
  snap:    { type: "spring", duration: 0.35, bounce: 0 },

  /** Gesture release. Sheets, drawers, swipe-to-dismiss. Barely-there settle. */
  gesture: { type: "spring", duration: 0.50, bounce: 0.18 },

  /** The one playful spring. Swap-direction flip, card flip. Nothing else. */
  playful: { type: "spring", duration: 0.45, bounce: 0.22 },

  /** Decorative pointer tracking — card tilt, magnetic hover. Never functional. */
  track:   { type: "spring", stiffness: 150, damping: 20, mass: 0.6 },
} as const;
```

<!-- plate:motion-springs -->

> **`bounce` never exceeds 0.25.** Financial UI doesn't wobble. Overshoot on a balance card looks unserious, and on a value it looks unstable.

**Use springs only where interruption is real.** A dialog that can't be grabbed mid-flight gains nothing from a spring and pays for it in main-thread work. Give it a duration and a curve.

---

## Stagger

```css
--stagger: 40ms;
--stagger-max-total: 240ms;   /* = 6 items */
```

<!-- plate:motion-stagger -->

Rules:
- **40ms between items.** Under 30 reads as simultaneous; over 80 reads as slow.
- **Cap the total at 240ms.** Items 7+ share item 6's delay. Without the cap, a 40-row table staggering at 40ms takes 1.6 seconds to finish.
- **Stagger is decorative and never blocks interaction.** Row 30 is clickable while rows 1–6 are still animating.
- **Only on first paint of a list.** Never on sort, filter, pagination, or re-render (Law 2).
- **Removed entirely under reduced motion.**

```ts
const delay = Math.min(index * 40, 240);
```

---

## Transform values

Keep them small. These values are the same everywhere in the library.

```css
--enter-y:        8px;      /* vertical entrance offset. Not 20px. */
--enter-y-sheet: 100%;      /* percentage: element's own height */
--enter-scale:  0.97;       /* entrance scale. NEVER 0. */
--press-scale:  0.97;       /* :active feedback */
--exit-scale:   0.98;       /* exits scale less than they enter */
--blur-mask:     2px;       /* crossfade masking */
--lift-y:       -1px;       /* hover lift on an interactive card. One pixel. */
```

### Never animate from `scale(0)`
Nothing in the physical world shrinks to nothing and reappears, so `scale(0)` looks wrong. Start at `0.95–0.97` with `opacity: 0`. Even a barely visible starting scale makes the entrance look natural.

### Percentages over pixels for full-element movement
`translateY(100%)` moves an element by its own height, whatever that is. Sheets hide off-screen and toasts enter this way, so the offset adapts to content with no magic number.

### 8px, not 20px
A 20–40px entrance offset is the most common tell of generated motion. Real interface motion is small: 8px reads as "arrived from below" without looking like the element traveled.

---

## Transform origin

```css
/* Popovers, dropdowns, menus, tooltips: scale from the TRIGGER */
.popover { transform-origin: var(--transform-origin); }        /* Base UI */
.popover { transform-origin: var(--radix-popover-content-transform-origin); }  /* Radix */

/* Dialogs: centered — they are not anchored to a trigger */
.dialog { transform-origin: center; }

/* Sheets: the edge they enter from */
.sheet-bottom { transform-origin: bottom; }
```

<!-- plate:motion-origin -->

You won't notice this on one popover, but you'll notice it across an app. A menu that scales from its button feels attached to it. One that scales from its own center feels like it landed there by accident.

---

## Z-index scale

Fixed. No arbitrary values, ever.

```css
--z-base:        0;
--z-raised:     10;   /* hover cards, elevated rows */
--z-sticky:     20;   /* sticky table headers, sticky totals */
--z-header:     30;   /* app chrome */
--z-dropdown:   40;   /* menus, selects, comboboxes */
--z-overlay:    50;   /* scrim */
--z-dialog:     60;   /* dialogs, sheets */
--z-popover:    70;   /* popovers over dialogs */
--z-toast:      80;
--z-tooltip:    90;
--z-command:   100;   /* command palette — above everything */
```

---

## Tailwind mapping

```js
// tailwind.config.ts
theme: {
  extend: {
    transitionTimingFunction: {
      out:      "cubic-bezier(0.23, 1, 0.32, 1)",
      "in-out": "cubic-bezier(0.77, 0, 0.175, 1)",
      drawer:   "cubic-bezier(0.32, 0.72, 0, 1)",
      standard: "cubic-bezier(0.4, 0, 0.2, 1)",
    },
    transitionDuration: {
      1: "100ms", 2: "140ms", 3: "180ms",
      4: "240ms", 5: "320ms", 6: "400ms",
    },
  },
}
```

Usage: `transition-transform duration-2 ease-out`.

**Never use `transition: all`.** It animates properties you didn't intend, including layout properties someone adds later, and it's the most common source of accidental jank. Name the properties.

---

## The complete default transition set

Copy-paste starting points. Any component whose motion spec departs from these has to say why.

```css
/* Press feedback — every pressable element */
.pressable {
  transition: transform var(--duration-1) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  .pressable:hover { /* hover state */ }
}
.pressable:active { transform: scale(var(--press-scale)); }

/* Popover / dropdown / menu */
.popover {
  transition:
    opacity   var(--duration-3) var(--ease-out),
    transform var(--duration-3) var(--ease-out);
  transform-origin: var(--transform-origin);
}
.popover[data-starting-style],
.popover[data-ending-style] {
  opacity: 0;
  transform: scale(var(--enter-scale));
}
.popover[data-ending-style] { transition-duration: 125ms; }

/* Tooltip — faster, and instant on subsequent hovers */
.tooltip {
  transition:
    opacity   var(--duration-2) var(--ease-out),
    transform var(--duration-2) var(--ease-out);
  transform-origin: var(--transform-origin);
}
.tooltip[data-instant] { transition-duration: 0ms; }

/* Dialog */
.dialog {
  transition:
    opacity   var(--duration-4) var(--ease-out),
    transform var(--duration-4) var(--ease-out);
  transform-origin: center;
}
.dialog[data-starting-style] { opacity: 0; transform: scale(var(--enter-scale)); }
.dialog[data-ending-style]   { opacity: 0; transform: scale(var(--exit-scale));
                               transition-duration: 165ms; }

/* Row hover — HIGH FREQUENCY. Color only, fast, no transform. */
.row { transition: background-color var(--duration-1) var(--ease-standard); }

/* Value flash — the live tick */
@keyframes value-flash {
  from { background-color: var(--flash-color); }
  to   { background-color: transparent; }
}
.value-flash {
  animation: value-flash var(--duration-flash) var(--ease-linear);
}
```

Entry uses `@starting-style` / `data-starting-style` instead of a `useEffect`-driven `mounted` flag. It's plain CSS: no JS and no extra render.
