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.
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
| CSS | Motion for React | Notes |
|---|---|---|
@keyframes with 0%, 50%, 100% | Keyframe arrays per property: y: [0, -40, 0] | One array per property, not one object per step |
| Keyframe percentages | times: [0, 0.5, 1] | Fractions from 0 to 1, same length as the arrays. Omit for even spacing |
animation-duration: 600ms | duration: 0.6 | Seconds, plain number |
animation-delay: 200ms | delay: 0.2 | Seconds, plain number |
animation-timing-function: ease-out | ease: [0, 0, 0.58, 1] or "easeOut" | Use cubic-bezier arrays to be exact |
animation-timing-function inside a keyframe | ease: [a, b, c], one entry per segment | The array has one fewer entry than the keyframes |
animation-iteration-count: infinite | repeat: Infinity | |
animation-iteration-count: 3 | repeat: 2 | Motion counts repeats after the first play |
animation-direction: alternate | repeatType: "reverse" | Needs repeat set as well |
animation-fill-mode: forwards | Default behaviour | Motion keeps the last keyframe |
animation-fill-mode: backwards | initial set to the first keyframe | Applies the start values during the delay |
animation-play-state: paused | useAnimate() 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.
.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:
- Split the transform. Two properties change,
translateYandscaleY, so we need two arrays:yandscaleY. - Read the values at each percentage.
yis 0, 0, -70, 0, 0.scaleYis 1, 0.8, 1.05, 0.85, 1. - Percentages to times. 0%, 15%, 50%, 85%, 100% become
[0, 0.15, 0.5, 0.85, 1]. - 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 toease-in-out. - Units and repeat. 1200ms becomes 1.2, 300ms becomes 0.3,
infinitebecomesrepeat: 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.
