Building a Motion System

How to build a motion token system that engineers actually use — primitives, semantics, enter/exit, and when CSS isn't enough.

Same <Modal> component. Four different teams. Four different implementations:

// Checkout team — default felt too slow
<Modal duration={100} />

// Onboarding team — needed it snappier on mobile
<Modal className={styles.fastModal} easing="linear" />

// Settings team — wanted more control over the curve
const AnimatedModal = motion(Modal)

// Growth team — just turned it off
<Modal disableAnimation={true} />

None of them wrong. The component wasn’t opinionated enough to stop them — it exposed the duration as a prop, so they tuned it. It didn’t ship a motion token, so they made their own. And now the same modal animates differently in checkout, onboarding, settings, and the growth experiments. Nobody noticed until a designer opened all four side by side.

That’s the real motion problem. Not that teams have bad instincts — they don’t. It’s that an unopinionated system invites divergence. The fix isn’t telling people to stop customizing. It’s making the right answer the default one.

Name the raw values

The first thing that needs names is the duration scale and the easing vocabulary. These are primitives — raw values, nothing composed yet.

:root {
  /* Duration */
  --duration-instant:    50ms;
  --duration-fast:      150ms;
  --duration-moderate:  300ms;
  --duration-slow:      500ms;
  --duration-deliberate: 800ms;

  /* Easing */
  --ease-linear:   linear;
  --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-enter:    cubic-bezier(0.0, 0, 0.2, 1);
  --ease-exit:     cubic-bezier(0.4, 0, 1, 1);
  --ease-spring:   cubic-bezier(0.34, 1.56, 0.64, 1);
}

Start all five at the same moment and you see exactly why the scale matters:

Duration scale — all five, same start
—duration-instantcheckbox · toggle · inline feedback
50ms
—duration-fasthover · click · tooltip
150ms
—duration-moderatemodal · drawer · popover
300ms
—duration-slowpage transition · skeleton load
500ms
—duration-deliberatehero entrance · onboarding
800ms
--duration-instant:    50ms;   /* checkbox, toggle, inline feedback */
--duration-fast:      150ms;   /* hover, click, tooltip */
--duration-moderate:  300ms;   /* modal, drawer, popover */
--duration-slow:      500ms;   /* page transition, skeleton */
--duration-deliberate: 800ms;  /* hero entrance, onboarding */

--duration-instant is done before --duration-deliberate has barely moved. That gap is the whole point — a checkbox clicking into place and a hero section announcing itself are completely different moments. The system needs both, and needs to know which is which.

Name the intents

Primitives tell you what the values are. Semantics tell you which one to reach for.

:root {
  --motion-hover:    var(--duration-fast)      var(--ease-standard);
  --motion-enter:    var(--duration-moderate)  var(--ease-enter);
  --motion-exit:     var(--duration-fast)      var(--ease-exit);
  --motion-page:     var(--duration-slow)      var(--ease-enter);
  --motion-spring:   var(--duration-moderate)  var(--ease-spring);
  --motion-feedback: var(--duration-instant)   var(--ease-standard);
}

A developer reaching for a tooltip animation shouldn’t think about milliseconds. They reach for --motion-enter and get the right duration and curve as a unit. The semantic layer is what turns a list of variables into a system.

.tooltip      { transition: opacity var(--motion-enter), transform var(--motion-enter); }
.drawer       { transition: transform var(--motion-page); }
.button:active { transition: transform var(--motion-feedback); }

The semantic layer is what would have stopped the four-team problem at the top. The checkout team passed duration={100} because the component default felt slow. With --motion-enter baked into the component and a token they can override at the system level, that conversation changes — one token update, consistent everywhere, instead of four diverging prop values.

The decision the system should encode for you

One of the first things I put into the semantic layer was the enter/exit split. It’s also the most common thing I fix in other people’s motion: entrances and exits using the same easing.

When something enters, it should decelerate into position — it’s arriving. ease-out: cubic-bezier(0.0, 0, 0.2, 1). When something exits, it should accelerate away — it’s leaving and needs to get out of the way. ease-in: cubic-bezier(0.4, 0, 1, 1). Exits should also be around 60% of the entrance duration. An element that leaves slowly is asking for attention on the way out.

Enter vs exit — open and close the drawer

Settings

Notifications · Privacy · Appearance

/* Enter: slides up, decelerates into position */
--ease-enter: cubic-bezier(0.0, 0, 0.2, 1);
transition: transform 300ms var(--ease-enter),
            opacity   300ms var(--ease-enter);
transform: translateY(0) scale(1); opacity: 1;

/* Exit: scales down slightly, accelerates away */
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
transition: transform 180ms var(--ease-exit),
            opacity   180ms var(--ease-exit);
transform: translateY(100%) scale(0.97); opacity: 0;

Open and close the drawer. The close feels faster even though you’re watching the same property animate — that’s the asymmetry working. Once you feel it you can’t unsee it in interfaces where it’s wrong. --motion-enter and --motion-exit encode this decision so nobody has to look it up.

The CSS boundary

Most of what I’ve seen built with Framer Motion for hover states, drawers, and dropdowns didn’t need it. CSS handles anything that stays in the DOM. The gap is lifecycle.

CSS can’t animate an element that doesn’t exist yet. If a modal is conditionally rendered, it’s removed from the DOM on close — and CSS has no way to run an exit animation before that happens. AnimatePresence from Framer Motion captures the exit before the node is removed. A tooltip that’s always mounted and toggled with opacity: 0 doesn’t need it.

// Framer Motion only where DOM lifecycle matters
import { AnimatePresence, motion } from 'framer-motion'

function Modal({ isOpen, children }) {
  return (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          initial={{ opacity: 0, y: 8 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: 8 }}
          transition={{ duration: 0.3, ease: [0.0, 0, 0.2, 1] }}
        >
          {children}
        </motion.div>
      )}
    </AnimatePresence>
  )
}

The easing array [0.0, 0, 0.2, 1] is --ease-enter. If that value changes in the token file, the Framer Motion config needs to match.

Easing carries meaning, not just timing

The --motion-spring token is the one I’ve seen misused most. Spring easing on a success state reads like a reward — the border bounces slightly into green and it feels like “nice work.” Spring easing on an error state looks like the form is celebrating your mistake. The curve carries meaning beyond its duration.

The rule I landed on: spring for positive feedback, standard for negative, instant for system states.

Easing by feedback type — pick a state
/* Success — spring: icon bounces in, feels like a reward */
.icon { transition: transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1); }

/* Error — shake: communicates rejection spatially */
@keyframes shake {
  0%, 100% { transform: translateX(0); }
  20%       { transform: translateX(-6px); }
  50%       { transform: translateX(6px); }
  80%       { transform: translateX(-4px); }
}
.input-error { animation: shake 300ms ease-in-out; }

/* Required — instant: system states just appear */
.input { transition: border-color 0ms; }

Writing this as a rule in a doc doesn’t work — I tried. People read “spring for positive” and spring everything that isn’t an explicit error. Putting all three next to each other is the only thing that made it click.

One more thing in the same file — prefers-reduced-motion. I’ve done this per-component before and missed things. Root-level is just safer:

@media (prefers-reduced-motion: reduce) {
  :root {
    --duration-instant:    0ms;
    --duration-fast:       0ms;
    --duration-moderate:   0ms;
    --duration-slow:       0ms;
    --duration-deliberate: 0ms;
  }
}

Setting durations to 0ms rather than removing transitions keeps state changes functional — they just happen instantly. For Framer Motion:

const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const duration = prefersReduced ? 0 : 0.3

After shipping this in the design system, the change wasn’t dramatic. Nobody sent a message saying the animations felt more consistent. What stopped was the Figma comments — designers flagging animation speed on individual components in review. They just stopped showing up. A motion system that works is one that nobody notices is there.