Motion Ratios That Don't Make Your UI Feel Cheap
Most UI animation feels off because the durations and easing curves are guessed, not designed. Here's the ratio system we use to make motion feel intentional across web and mobile.

There's a specific kind of cheapness you feel the moment a modal slides in too slowly, or a button bounces like it's auditioning for a children's app. The animation isn't broken — it's just uncalibrated. After enough projects, we stopped guessing durations and started treating motion like a token system with ratios, the same way typography uses a modular scale.
This is the system we reach for when a design lead says "the animations feel off, but I can't tell you why."
Why "feels off" is almost always a ratio problem
When motion feels wrong, engineers tend to fiddle with one number at a time. The modal is too slow, so we drop it from 400ms to 250ms. Now the backdrop fade looks disconnected. We speed that up too. Then the close animation feels abrupt, so we add a spring. Three weeks later the app has 17 different durations and no one remembers why.
The fix isn't picking better numbers. It's picking fewer numbers and applying them in proportional relationships.
A motion system needs three things:
- A small set of base durations (usually 4–6)
- A small set of easing curves (3 is enough)
- Rules about which combinations go with which interaction types
That's it. Everything else — staggers, delays, exits — is derived.
The duration scale we actually use
We borrow the same logic as a type scale: pick a base, then multiply. Our default base is 100ms, with a 1.5× ratio rounded to friendly numbers.
// motion.tokens.ts
export const duration = {
instant: 80, // hover states, focus rings, tooltips
fast: 160, // small UI: chips, switches, dropdown items
base: 240, // standard: buttons, inputs, accordions
medium: 360, // panels, drawers, modal content
slow: 520, // page transitions, hero reveals
glacial: 800, // onboarding flourishes, empty-state intros
} as const;
The rule we enforce in code review: no animation gets a custom duration. If something doesn't fit, we argue about which bucket it belongs to, not which number is right. That single constraint kills 80% of the inconsistency.
Why 100ms is the floor
Anything under ~80ms reads as instant to the human visual system, so it's not really animation — it's just a state change with a cushion. Below that and you're paying a complexity cost for something nobody perceives. We've tested this on slow Android devices too: rendering a 40ms transition and skipping it entirely looks identical to most users.
Why we cap around 500ms for interactive elements
Nielsen Norman has written about this for years, but the practical version: if a user triggered the action, they want to see the result. Past roughly half a second, the animation stops feeling like feedback and starts feeling like waiting. Reserve the longer buckets for content the user didn't ask for — onboarding, page-level transitions, decorative reveals.
Three easings, not thirty
Figma's animation panel lets you pick from a dozen curves. Resist. We use exactly three, named by intent:
:root {
/* Enter: decelerating — content arrives and settles */
--ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
/* Exit: accelerating — content leaves with intent */
--ease-exit: cubic-bezier(0.7, 0, 0.84, 0);
/* Move: symmetric — for things that travel between two states */
--ease-move: cubic-bezier(0.65, 0, 0.35, 1);
}
The semantic naming matters more than the curve values. Once your team thinks in enter / exit / move instead of ease-in-out-back-quart, animation reviews get dramatically faster.
Spring animations have their place — particularly for direct manipulation like drag-and-drop — but for state transitions, well-tuned cubic-béziers are more predictable across devices and easier to reason about in testing.
The pairing rules
This is where ratios earn their keep. Durations and easings aren't picked independently; they're paired by interaction type.
| Interaction | Duration | Easing |
|---|---|---|
| Hover / focus | instant | move |
| Toggle, checkbox | fast | move |
| Button press → result | base | exit then enter |
| Modal / drawer open | medium | enter |
| Modal / drawer close | base | exit |
| Page transition | slow | enter |
Notice that close is faster than open, by roughly a 2:3 ratio. This is the single highest-leverage rule in the whole system. When users dismiss something, they've already decided to move on — the UI shouldn't make them wait. When something opens, a slightly longer duration gives the eye time to register what arrived.
We got this wrong for years before noticing. Symmetric open/close animations feel sluggish on the way out, every single time.
Stagger ratios for lists
When multiple items animate together — a dropdown menu, a search-results grid, a sidebar of nav items — stagger them at roughly 1/6 of the base duration:
// Framer Motion example
const container = {
animate: {
transition: { staggerChildren: 40 / 1000 } // ~base/6
}
};
const item = {
initial: { opacity: 0, y: 8 },
animate: {
opacity: 1,
y: 0,
transition: { duration: 0.24, ease: [0.16, 1, 0.3, 1] }
}
};
For lists longer than 8 items, cap the total stagger window — otherwise the last item lags long enough to feel broken. We usually cap at 320ms regardless of list length and let later items overlap their entrances.
Accessibility isn't optional here
Motion that ignores prefers-reduced-motion will get flagged in any serious accessibility audit, and rightly so. About 35% of users in some studies report sensitivity to motion, ranging from mild distraction to actual vestibular symptoms.
The right pattern isn't to disable motion entirely — that often breaks the perceived causality of state changes. Instead, reduce distance and scale while keeping a short fade:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.12s !important;
animation-iteration-count: 1 !important;
}
}
And in component code, branch the animation itself:
const prefersReduced = useReducedMotion();
const variants = prefersReduced
? { initial: { opacity: 0 }, animate: { opacity: 1 } }
: { initial: { opacity: 0, y: 12 }, animate: { opacity: 1, y: 0 } };
Users still get feedback that something changed. They don't get the sliding, scaling, or parallax that triggers symptoms.
Where this falls apart
A ratio system is a tool, not a religion. Three places it tends to break:
Direct manipulation. When a user is dragging something, motion should be 1:1 with their input — no easing, no duration. The system only kicks in when the user lets go.
Loading and progress. Skeletons, spinners, and progress indicators have their own logic driven by perceived performance, not aesthetic ratio. Keep them out of the token system entirely.
Marketing pages. Hero reveals and scroll-driven storytelling have different goals than product UI. The product team's motion tokens shouldn't constrain the landing page, and vice versa. Two systems, clearly scoped.
Auditing an existing codebase
If you're inheriting a codebase with motion sprawl, don't try to fix it all at once. We do a two-week audit:
- Grep for every
transition,animate,duration, andeasein the codebase. - Bucket the actual values into a histogram. You'll usually find 20+ unique durations clustering around 5 real intentions.
- Propose token names matching those clusters.
- Replace top-down by component, starting with the most-used primitives (buttons, inputs, modals).
Don't try to consolidate easings and durations simultaneously. Pick one. Durations first, usually — the values are easier to compare than curves.
What we'd do on Monday
If you're inheriting motion chaos, start with a single PR that adds a motion.tokens.ts file with six durations and three easings. Don't refactor anything yet. Just get the vocabulary into the codebase.
Then, in the next sprint, pick one primitive — the button or the modal — and convert it. Measure how much animation code disappeared. Show the diff in design review. The argument for rolling it out across the rest of the system will make itself.
If you want help building motion tokens into a broader design system, our design and engineering teams do this kind of work regularly. The system above is roughly what we ship by default.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Modal Dialogs Are Where Accessibility Goes to Die
Modals look simple in Figma and break in twelve different ways in production. Here's the focus trap, scroll lock, and ESC handling we actually ship — and why the native <dialog> element finally earns its keep in 2026.

Design Tokens That Survive Contact With Engineering
Most design token systems look great in Figma and fall apart in the codebase. Here's how to structure tokens so they actually hold up across Tailwind, iOS, and three product teams.

Empty States Are Your Best Onboarding Surface — Stop Wasting Them
Most empty states show a sad cloud and a 'No data yet' label. That's a dead pixel. Here's how to turn the zero state into the most persuasive screen in your product.
