All articles
Design & UXSeptember 18, 2026 6 min read

Motion Ratios: How to Pick Animation Durations That Don't Feel Cheap

Most product animations feel off because durations are chosen by vibe. Here's how to build a motion ratio scale that makes 200ms feel intentional and 400ms feel earned.

Pick any three production apps and time their animations with a stopwatch. You'll find durations scattered between 150ms and 600ms with no discernible logic, easing curves copy-pasted from a Dribbble shot, and modals that ease in slower than they ease out for no reason anyone can defend. Motion is the last part of most design systems to get rationalised, and it shows.

This is a working system for choosing animation durations — not a philosophy, not a manifesto, just a ratio scale and a few rules that survive contact with real components.

Why durations feel wrong

Humans read motion the same way we read typography: relatively. A 300ms fade feels fast next to a 600ms drawer and slow next to a 100ms hover. The absolute number matters less than its relationship to the other motion on screen.

The two failure modes we see constantly:

  • Uniform durations. Everything is 300ms because someone read that 300ms is the "sweet spot." Tooltips crawl. Page transitions feel snappy but jarring. Nothing has hierarchy.
  • Random durations. Each component was tuned in isolation by whoever built it. The result is a UI that feels like it was assembled from three different products, which it usually was.

Both problems disappear once you commit to a scale.

The ratio: 1.5x, not linear

Borrow from type scales. A modular scale with a ratio of roughly 1.5 gives you enough separation between steps that they feel distinct, without exploding into unusable extremes.

Here's the base scale we use as a starting point:

export const duration = {
  instant: 80,   // micro-feedback: button press, checkbox tick
  fast:    120,  // hover, focus rings, small state changes
  base:    200,  // tooltips, small popovers, tab switches
  moderate: 320, // dropdowns, accordions, inline expansions
  slow:    500,  // modals, drawers, sheets, route transitions
  deliberate: 800 // onboarding sequences, celebratory moments
} as const;

Notice the ratio isn't strictly 1.5x — it's tuned. 80ms to 120ms is closer to 1.5, but 320 to 500 widens because at longer durations, the perceived difference between adjacent steps has to grow to stay legible. This is the same reason type scales often widen at the top.

Why not just use ms values ad-hoc?

Because the moment you have six components animating, you need a way to say "this is faster than that" without a stopwatch. Named tokens force the conversation to happen at design time, not in a bug ticket six months later.

Distance changes everything

Here's the rule most teams miss: duration should scale with the distance the element travels or the area it covers.

A dropdown that expands 40px shouldn't take the same time as a full-screen sheet that slides 900px. If they do, the dropdown feels sluggish and the sheet feels violent.

A rough heuristic we use:

  • Under 100px of movement → fast or base
  • 100 – 400px → moderate
  • 400px+ or full-viewport → slow
  • Full-screen route transitions → slow to deliberate, but split into phases

For properties that aren't spatial (opacity, color, blur), lean shorter. A pure fade almost never needs more than base (200ms). Fades that last 500ms read as broken.

Enter vs exit: asymmetry is the point

A symmetric animation — same duration in and out — feels robotic. Real objects don't behave that way, and neither should UI.

The rule we default to:

Exits should be 60 – 75% of the enter duration.

Why: users have already processed the element. On exit, they want it gone. Making them wait for a slow fade-out is friction disguised as polish.

const modal = {
  enter: duration.slow,      // 500ms
  exit:  duration.moderate,  // 320ms — ~64% of enter
};

const tooltip = {
  enter: duration.base,      // 200ms
  exit:  duration.fast,      // 120ms
};

The exception: destructive or irreversible actions. A delete confirmation dismissing should feel deliberate, not eager. Match durations there.

Easing: three curves, not thirty

Easing is where teams over-engineer. You do not need a curve for every component. You need three:

export const easing = {
  // Elements entering the screen. Decelerates into place.
  out: 'cubic-bezier(0.16, 1, 0.3, 1)',

  // Elements leaving. Accelerates away.
  in: 'cubic-bezier(0.7, 0, 0.84, 0)',

  // Elements moving within the screen (repositioning, morphing).
  inOut: 'cubic-bezier(0.87, 0, 0.13, 1)'
} as const;

The mapping:

  • Enter → out. The element is arriving. Fast start, soft landing. This is what makes UI feel responsive without feeling twitchy.
  • Exit → in. The element is leaving. Slow start, fast finish. Feels like it's being pulled out rather than fading.
  • Move → inOut. Anything shifting position while remaining on screen. Symmetric, because the user is tracking it the whole way.

Linear easing is reserved for two things only: continuous loops (spinners, progress bars) and color transitions where the perceptual midpoint matters more than the motion shape.

Springs are not a free lunch

Spring physics (Framer Motion's spring, iOS-style bounces) look great in demos and terrible in dense product UI. They add unpredictable duration, which breaks orchestration — you can't chain animations reliably when the first one might take 340ms or 480ms depending on damping.

Use springs for: playful accents, drag interactions, hero moments. Use tweens for: everything in your CRUD app.

Putting it together: a real component

Here's a dropdown menu with the scale applied. The values are tokens, not magic numbers, and the enter/exit relationship is explicit:

import { motion, AnimatePresence } from 'framer-motion';
import { duration, easing } from '@/tokens/motion';

function Dropdown({ open, children }) {
  return (
    <AnimatePresence>
      {open && (
        <motion.div
          initial={{ opacity: 0, y: -8, scale: 0.98 }}
          animate={{ opacity: 1, y: 0, scale: 1 }}
          exit={{ opacity: 0, y: -4, scale: 0.99 }}
          transition={{
            duration: duration.moderate / 1000,
            ease: easing.out,
            // Exit gets its own, shorter timing
            exit: {
              duration: duration.base / 1000,
              ease: easing.in
            }
          }}
        >
          {children}
        </motion.div>
      )}
    </AnimatePresence>
  );
}

A few things worth noting:

  • The exit y is smaller (-4 vs -8). Less distance on exit reinforces the shorter duration — the element doesn't need to travel as far because we want it gone.
  • Scale change on entry is subtle (0.98 → 1). Motion for its own sake reads as cheap. This is barely perceptible, which is the point — it makes the fade feel weighted.
  • All timing values come from tokens. Six months from now, tuning the whole system's tempo is one file change.

Respecting reduced motion

This is not optional. It has been in the spec for years and it's still the first thing skipped.

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    transition-duration: 1ms !important;
    animation-iteration-count: 1 !important;
  }
}

A nuclear override works, but it's crude. Better: keep opacity transitions (they don't trigger vestibular issues) and kill only transform-based motion. Something like:

const prefersReducedMotion =
  typeof window !== 'undefined' &&
  window.matchMedia('(prefers-reduced-motion: reduce)').matches;

const motionSafe = (config) =>
  prefersReducedMotion
    ? { ...config, y: 0, scale: 1, duration: 0.01 }
    : config;

Test it. Turn on Reduce Motion in your OS and click through your product. If anything moves noticeably, fix it.

Where we'd start

If you're retrofitting this on an existing product, don't try to boil the ocean. Do this in order:

  1. Ship the tokens. Even if nothing consumes them yet, get duration and easing into your design system package. This forces the conversation.
  2. Audit your five most-used components. Modals, dropdowns, tooltips, toasts, tabs. Time them. Rewrite them against the scale. Most teams find 40 – 60% of their motion values were wrong.
  3. Add the reduced-motion query. Non-negotiable, ten minutes of work.
  4. Document the enter/exit asymmetry rule somewhere engineers will actually read it — a README in the components folder beats a Notion page nobody opens.

Motion is one of those things where 80% of the wins come from consistency, not craft. Get the scale right, apply it everywhere, and your product will feel more expensive than it did yesterday — without a single new animation.

If you want a hand rationalising an existing design system, our design and engineering teams do exactly this kind of work.

#motion#design-systems#animation#ux

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project