Motion tokens
Every animation in Lopifi uses a token. Ad-hoc durations and inline cubic-béziers are a review failure — 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. 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
--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.
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 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
--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.
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.
--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
/* 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.
--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 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.
/* 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.