Motion tokens
Every animation in Lopifi uses a token. An ad-hoc duration or inline cubic-bézier fails review; see quality bar.
Easing#
/* 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. 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#
--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. 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.
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;
bouncenever 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#
--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. 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.
const delay = Math.min(index * 40, 240);
Transform values#
Keep them small. These values are the same everywhere in the library.
--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#
/* 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; }
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.
--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#
// 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.
/* 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.