Tutorials

Convert CSS keyframes to Framer Motion (Motion for React)

Map every part of a CSS @keyframes animation to Motion for React: keyframe arrays, times, duration, ease arrays, repeat and repeatType, with a worked example.

· 7 min read

You have a CSS animation that works, and a React codebase that uses Motion (the library formerly called Framer Motion). Moving the animation across is mostly mechanical, but a few details trip people up: units, easing names, how percentages become arrays, and how iteration counts are counted.

This guide gives the full mapping as a table, then converts a real multi-step animation line by line. Code uses the current package name, motion, with imports from motion/react; the same options work with the older framer-motion import.

The mapping table

CSSMotion for ReactNotes
@keyframes with 0%, 50%, 100%Keyframe arrays per property: y: [0, -40, 0]One array per property, not one object per step
Keyframe percentagestimes: [0, 0.5, 1]Fractions from 0 to 1, same length as the arrays. Omit for even spacing
animation-duration: 600msduration: 0.6Seconds, plain number
animation-delay: 200msdelay: 0.2Seconds, plain number
animation-timing-function: ease-outease: [0, 0, 0.58, 1] or "easeOut"Use cubic-bezier arrays to be exact
animation-timing-function inside a keyframeease: [a, b, c], one entry per segmentThe array has one fewer entry than the keyframes
animation-iteration-count: infiniterepeat: Infinity
animation-iteration-count: 3repeat: 2Motion counts repeats after the first play
animation-direction: alternaterepeatType: "reverse"Needs repeat set as well
animation-fill-mode: forwardsDefault behaviourMotion keeps the last keyframe
animation-fill-mode: backwardsinitial set to the first keyframeApplies the start values during the delay
animation-play-state: pauseduseAnimate() controls, .pause()Imperative, not a prop

The rest of this article explains the rows that need judgement.

Percentages become arrays and times

CSS groups values by moment: at 50%, set this transform and that opacity. Motion groups by property: here is every value y passes through. To convert, list each property's value at every percentage that appears anywhere in the keyframes, then turn the percentages into fractions for times.

If a property is missing from one keyframe, CSS interpolates it between the neighbours that do mention it. In Motion every array must have the same number of entries as times, so you fill the gap yourself, either with the interpolated value or with null. A null keyframe holds the previous value in the array, which is not always what CSS did, so check the result.

Transforms need one more step. CSS writes them as one string, transform: translateY(-40px) scaleY(1.05). Motion animates each part as its own value: y, x, scale, scaleX, scaleY, rotate. Motion always composes them in the order translate, then scale, then rotate. If your CSS applies them in a different order, for example rotate before translate, the result will differ.

Durations, delays and units

This is the most common bug in converted animations. CSS times carry a unit and are usually written in milliseconds. Motion takes a plain number in seconds. animation-duration: 600ms is duration: 0.6. Write duration: 600 and your animation takes ten minutes.

Also note the defaults differ. CSS has no default duration (it is 0s, so nothing runs). Motion uses 0.3 s for a two-value tween and 0.8 s when you pass more than two keyframes, and it uses a spring by default for single-target transform values like x or scale. When you convert, set duration and ease explicitly so you get a tween with the timing you had.

Easing: names, arrays and per-keyframe curves

Motion understands "linear", "easeIn", "easeOut" and "easeInOut", which match CSS linear, ease-in, ease-out and ease-in-out. There is no named equivalent of the CSS default ease, which is cubic-bezier(.25, .1, .25, 1). The safe habit is to convert every curve to a four-number array:

// CSS                                  Motion
// ease                          ->     [0.25, 0.1, 0.25, 1]
// ease-in                       ->     [0.42, 0, 1, 1]
// ease-out                      ->     [0, 0, 0.58, 1]
// ease-in-out                   ->     [0.42, 0, 0.58, 1]
// cubic-bezier(.33, 1, .68, 1)  ->     [0.33, 1, 0.68, 1]

Per-keyframe easing is where most hand conversions go wrong. In CSS, an animation-timing-function inside a keyframe applies to the segment that starts at that keyframe and runs to the next one. Keyframes without their own timing function use the one from the animation shorthand. In Motion, ease can be an array with one curve per segment, so for five keyframes you pass four curves, in order. More on the curves themselves in CSS easing functions explained.

Repeat, direction and fill mode

animation-iteration-count counts total plays; Motion's repeat counts extra plays. infinite becomes repeat: Infinity, and a count of 3 becomes repeat: 2.

animation-direction: alternate becomes repeatType: "reverse". Both play every other iteration backwards in time, which also reverses the easing: an ease-in going forward looks like an ease-out coming back. Motion also has repeatType: "mirror", which swaps the start and end values but keeps the easing running forward. That has no CSS equivalent. repeatType: "loop", the default, matches normal. Motion adds repeatDelay, a pause between iterations, which in CSS you have to fake with a stretch of identical keyframes.

For fill mode: Motion leaves the element on its final keyframe, like forwards. If your CSS used none and the element snapped back to its base style, make the last keyframe equal the base style. For backwards or both with a delay, set initial to the first keyframe so the element does not sit in its base style while it waits.

Worked example: a squash-and-hop

Here is a small looping animation in CSS: a ball squashes, jumps, lands and recovers. It uses five keyframes, two per-keyframe easings, a delay and an infinite loop.

The CSS version, running live
.ball {
  transform-origin: 50% 100%;
  animation: hop 1200ms ease-in-out 300ms infinite;
}

@keyframes hop {
  0%   { transform: translateY(0) scaleY(1); }
  15%  { transform: translateY(0) scaleY(.8);
         animation-timing-function: cubic-bezier(.33, 1, .68, 1); }
  50%  { transform: translateY(-70px) scaleY(1.05);
         animation-timing-function: cubic-bezier(.32, 0, .67, 0); }
  85%  { transform: translateY(0) scaleY(.85); }
  100% { transform: translateY(0) scaleY(1); }
}

Step by step:

  1. Split the transform. Two properties change, translateY and scaleY, so we need two arrays: y and scaleY.
  2. Read the values at each percentage. y is 0, 0, -70, 0, 0. scaleY is 1, 0.8, 1.05, 0.85, 1.
  3. Percentages to times. 0%, 15%, 50%, 85%, 100% become [0, 0.15, 0.5, 0.85, 1].
  4. One easing per segment. There are four segments. 0–15% has no override, so it uses the shorthand's ease-in-out. 15–50% uses the curve set at 15%, an ease-out for the rise. 50–85% uses the curve set at 50%, an ease-in for the fall. 85–100% falls back to ease-in-out.
  5. Units and repeat. 1200ms becomes 1.2, 300ms becomes 0.3, infinite becomes repeat: Infinity.
import { motion } from "motion/react";

const easeInOut = [0.42, 0, 0.58, 1];

export function HopBall() {
  return (
    <motion.div
      className="ball"
      style={{ transformOrigin: "50% 100%" }}
      animate={{
        y: [0, 0, -70, 0, 0],
        scaleY: [1, 0.8, 1.05, 0.85, 1],
      }}
      transition={{
        duration: 1.2,
        delay: 0.3,
        times: [0, 0.15, 0.5, 0.85, 1],
        ease: [easeInOut, [0.33, 1, 0.68, 1], [0.32, 0, 0.67, 0], easeInOut],
        repeat: Infinity,
      }}
    />
  );
}

One difference to know about: CSS applies animation-delay once, before the first iteration, and Motion does the same with delay. If you want a pause before every iteration, add repeatDelay. Here the first and last keyframes are identical, so the loop has no visible jump either way.

If the two properties needed different timing, you could give each its own transition: transition={{ y: { ... }, scaleY: { ... } }}. That is the Motion equivalent of running two separate CSS animations on one element.

When to keep it in CSS

Converting is worth it when the animation depends on React state: it should start when something mounts, stop when something leaves, react to a hover or drag, or animate layout. A spinner or a decorative loop that runs forever gains nothing from Motion and costs a JavaScript animation instead of one the browser can run on its own. Leave those in CSS and keep the class on the element. For an entrance across many items, the Framer Motion stagger guide shows both versions side by side.

Skip the conversion

If the animation you want to convert lives on someone else's site, you do not need the CSS first. Find it with Chrome DevTools, or open the page with the Capture Motion extension, which records the keyframes, durations, delays and easing of every animated property, whether the site used CSS, the Web Animations API, GSAP or Lottie. The extension popup shows the capture as CSS, React and GSAP code with a Copy button, so you can take the React version directly. It also exports HTML and CSS, JSON, an AI prompt or a Figma Motion timeline. More on that workflow in animation to code.

Questions

Why does my converted animation run at the wrong speed?

Almost always units. CSS durations and delays are in milliseconds or seconds with a unit; Motion takes plain numbers in seconds. 600ms in CSS is duration: 0.6 in Motion, not 600.

Can I pass the CSS keyword ease to Motion?

Not reliably. Motion's named easings are linear, easeIn, easeOut, easeInOut and a few extras, with no plain "ease". Write CSS ease as the array [0.25, 0.1, 0.25, 1] instead.

How do I convert animation-iteration-count: 3?

Use repeat: 2. CSS counts total plays, while Motion's repeat counts plays after the first one. infinite becomes repeat: Infinity.

Should I convert CSS animations to Motion at all?

Only when you need what React adds: animating on state changes, exit animations, gestures or layout. A looping decorative animation is usually fine left in CSS.