# Motion tokens

> Every animation in Lopifi uses a token. Ad-hoc durations and inline cubic-béziers are a review failure — see quality bar.

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

---

# Motion tokens

Every animation in Lopifi uses a token. Ad-hoc durations and inline cubic-béziers are a review failure — see [quality bar](../craft/03-quality-bar.md).

---

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

**There is no `--ease-in`.** Deliberately absent from the token file so it can't be reached for. If a curve is needed that isn't here, it's a design conversation, not a CSS decision.

The built-in CSS keywords (`ease`, `ease-out`, `ease-in-out`) are too weak to read as intentional. `cubic-bezier(0.23, 1, 0.32, 1)` moves about two-thirds of the distance in the first 20% of the time — that's what makes it feel like the interface responded rather than played 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 */
```

### Exit durations
**Exit = enter × 0.7, floored at 100ms.** Not a separate token set — computed, and encoded in each component's spec:

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

The asymmetry has a reason: entering, the user is discovering the element and the motion explains where it came from. Exiting, 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

For anything a pointer can grab. Apple's `duration`/`bounce` parameterization — easier to reason about than mass/stiffness/damping, and it maps cleanly 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;
```

> **`bounce` never exceeds 0.25.** Financial UI does not wobble. Overshoot on a balance card reads as unserious, and on a value it reads as unstable.

**Springs only where interruption is real.** A dialog that can't be grabbed mid-flight gains nothing from a spring and costs main-thread work — use a duration and a curve.

---

## Stagger

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

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. A 40-row table staggering at 40ms takes 1.6 seconds to finish, which is unusable — and this cap is the fix nobody implements.
- **Stagger is decorative and never blocks interaction.** Row 30 must be 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

Small. Consistently small. This is what separates a considered system from a generic one — the values below never change, anywhere 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 vanishes to nothing and reappears. `scale(0)` looks like the element came from a singularity. Start at `0.95–0.97` with `opacity: 0` — even a barely-visible initial scale makes the entrance read as natural.

### Percentages over pixels for full-element movement
`translateY(100%)` moves an element by its own height regardless of its actual size. That's how sheets hide off-screen and how toasts enter — it adapts to content and never needs a magic number.

### 8px, not 20px
The most common AI-generated motion tell is a 20–40px entrance offset. Real interface motion is small. 8px is enough to read 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; }
```

Getting this right on a popover is invisible individually and unmistakable in aggregate — 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 `transition: all`.** It animates properties you didn't intend (including layout properties added later by someone else) and it's the single most common source of accidental jank. Always name the properties.

---

## The complete default transition set

Copy-paste starting points. Every component's motion spec is a deviation from these, and must 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);
}
```

Note `@starting-style` / `data-starting-style` rather than a `useEffect`-driven `mounted` flag — the modern CSS way to animate entry, no JS, no extra render.
