TL;DR Put durations and easings in CSS variables and one lib/motion.ts hook, write a .cursor/rules/motion.mdc rule with globs that names the exact classes to use, link a real example component, and run a small check script that fails on raw durations, inline curves, transition-all and animated height.
Cursor rules keep UI animations consistent when they point at something concrete. A line like "use subtle animations" gets you a different duration and easing curve in every component. What works better is three pieces: a small set of motion tokens in your CSS and one TypeScript file, a project rule in .cursor/rules/motion.mdc that says to use only those tokens and to animate only transform and opacity, and a short script that fails when a component skips the tokens. The rule tells the agent what to do. The script catches the times it doesn't.
I make qodesign, a set of React + TypeScript components with Tailwind (most also use Framer Motion), so I spend a lot of time looking at agent-written animation code. I built everything below in a fresh Next.js 16.4.2 app with React 19.3, Tailwind v4 and Motion 14.4, ran tsc and next build, and measured the result in headless Chromium with Reduce Motion off and on. One thing I can't measure from here is how often Cursor follows the rule. That depends on the model and the prompt, which is the reason the check script exists.
If you want the general version of this for colors, spacing and components, I wrote about Cursor rules for a design system earlier. This post is only about motion.
Ask an agent for a dropdown, then a modal, then a toast, and you can easily end up with three timings: duration-300 in one, transition={{ duration: 0.35 }} in the next, an ease-[cubic-bezier(0.4,0,0.2,1)] copied from somewhere in the third. Each one looks fine on its own. Together, the app feels like it was built by several people, which it sort of was.
The other common slip is animating layout. Animating height or using transition-all can make the browser recalculate layout on every frame, while transform and opacity usually don't. Height is also the most literal way to describe "expand", so it's an easy thing for an agent to reach for.
This is the component an agent tends to write when nothing tells it otherwise. I used it as the bad example in the test app:
That gives you ease-out-soft, ease-in-out-soft and animate-rise as classes, plus duration-(--motion-fast) and friends.
Motion takes durations in seconds and doesn't read CSS variables for them, so it gets a matching TypeScript file. The hook returns one transition per speed and makes everything instant when Reduce Motion is on. Motion's useReducedMotion hook returns true when the device has Reduce Motion enabled:
ts
// lib/motion.ts"use client";import { useReducedMotion, type Transition } from "motion/react";// Seconds, because Motion takes seconds. Keep these equal to the CSS tokens in globals.css.export const duration = { fast: 0.15, base: 0.24, slow: 0.4 } as const;export const ease = { outSoft: [0.22, 1, 0.36, 1], inOutSoft: [0.65, 0, 0.35, 1],} as const;export type Speed = keyof typeof duration;// One transition per speed. With Reduce Motion on, every transition becomes instant.export function useMotionTransition(speed: Speed = "base"): Transition { const reduce = useReducedMotion(); if (reduce) return { duration: 0 }; return { duration: duration[speed], ease: ease.outSoft };}
I used a hook instead of MotionConfig reducedMotion="user" on purpose. When I tested shadcn components earlier, reducedMotion="user" stopped an x animation but not a whileHover scale (that's in the shadcn animation and accessibility post). Setting the duration to zero covers both.
The expand/collapse here fades and slides the panel 8px instead of animating its height. It also runs through AnimatePresence, so the exit animation plays before the panel unmounts.
Cursor's docs say project rules live in .cursor/rules as .mdc files with description, globs and alwaysApply in the frontmatter. A plain .md file in that folder is ignored, and AGENTS.md is the plain-markdown alternative. Setting globs with alwaysApply: false makes it an "Apply to Specific Files" rule, so it loads when the agent works on a matching file. The docs also suggest keeping rules focused, under 500 lines, and pointing at concrete example files.
md
---description: Motion rules for any component that animatesglobs: app/**/*.tsx, components/**/*.tsxalwaysApply: false---- Use only the motion tokens. Durations: `--motion-fast` (150ms), `--motion-base` (240ms), `--motion-slow` (400ms). Easing: `ease-out-soft` for things entering, `ease-in-out-soft` for things moving across the screen.- In Tailwind, write `duration-(--motion-base) ease-out-soft`. Never `duration-300`, `duration-[350ms]` or `ease-[cubic-bezier(...)]`.- In Motion, get the transition from `useMotionTransition()` in `@/lib/motion`. Never write `duration:` or `ease:` inline.- Animate `transform` and `opacity` only. No animating width, height, top, left, margin or padding. For expand/collapse, fade and slide the content instead.- Name the property: `transition-transform` or `transition-opacity`, never `transition-all`.- Reduce Motion is handled by the tokens (they drop to 0ms) and by `useMotionTransition()`. Don't add a second check.- Example to copy: `@/components/good-panel.tsx`.- After editing, run `node scripts/check-motion.mjs` and fix anything it prints.
Each line names the exact class or function to use. "Keep animations subtle" gives the agent nothing to check its own output against, while "never duration-300" does.
The last line of the rule asks the agent to run a script. You can run the same script yourself, or in CI, so it doesn't matter whether the agent remembered:
js
#!/usr/bin/env node// scripts/check-motion.mjs// Flags motion that skips the tokens in app/globals.css and lib/motion.ts.// Usage: node scripts/check-motion.mjs [dir or file ...] (exits 1 when it finds something)import { readFileSync, readdirSync, statSync } from "node:fs";import { join, extname } from "node:path";const roots = process.argv.slice(2).length ? process.argv.slice(2) : ["app", "components"];const skip = new Set(["node_modules", ".next"]);const allowFiles = ["lib/motion.ts", "app/globals.css"];const rules = [ [/\bduration-(\d+|\[[^\]]+\])/g, "raw Tailwind duration, use duration-(--motion-fast|base|slow)"], [/\bease-\[[^\]]+\]/g, "inline easing curve, use ease-out-soft or ease-in-out-soft"], [/\btransition-all\b/g, "transition-all animates layout too, name the property (transition-transform, transition-opacity)"], [/\bduration:\s*[\d.]+/g, "literal Motion duration, use useMotionTransition()"], [/\bease:\s*\[/g, "inline Motion easing, use useMotionTransition()"], [/\b(?:animate|initial|exit|whileHover|whileTap|whileInView)=\{\{[^}]*\b(width|height|top|left|right|bottom|margin\w*|padding\w*)\s*:/g, "animating a layout property, animate transform or opacity instead"],];function walk(dir, out = []) { if (!statSync(dir).isDirectory()) return [dir]; for (const name of readdirSync(dir)) { if (skip.has(name)) continue; const p = join(dir, name); if (statSync(p).isDirectory()) walk(p, out); else if ([".ts", ".tsx", ".css"].includes(extname(p))) out.push(p); } return out;}let problems = 0;for (const file of roots.flatMap((r) => walk(r))) { if (allowFiles.some((a) => file.endsWith(a))) continue; const lines = readFileSync(file, "utf8").split("\n"); lines.forEach((line, i) => { for (const [re, msg] of rules) { for (const m of line.matchAll(re)) { problems++; console.log(`${file}:${i + 1} ${m[0]} ${msg}`); } } });}console.log(problems ? `\n${problems} motion problem(s)` : "motion check passed");process.exit(problems ? 1 : 0);
On the bad component it printed six problems and exited with code 1:
components/bad-panel.tsx:14 duration-300 raw Tailwind duration, use duration-(--motion-fast|base|slow)
components/bad-panel.tsx:14 ease-[cubic-bezier(0.4,0,0.2,1)] inline easing curve, use ease-out-soft or ease-in-out-soft
components/bad-panel.tsx:14 transition-all transition-all animates layout too, name the property (transition-transform, transition-opacity)
components/bad-panel.tsx:19 animate={{ height: animating a layout property, animate transform or opacity instead
components/bad-panel.tsx:20 duration: 0.35 literal Motion duration, use useMotionTransition()
components/bad-panel.tsx:20 ease: [ inline Motion easing, use useMotionTransition()
On the good component and the page it printed "motion check passed" and exited with 0. It's a line-by-line regex check, so it has limits: an animate={{ ... }} object split across several lines won't match the layout rule, and it can't tell a duration in a comment from one in code. It still catches the slips from the bad example above.
tsc and next build both passed, with all routes prerendered as static. Then I loaded the page in headless Chromium at 390px wide, once with Reduce Motion off and once on, and read the computed styles:
Measured
Reduce Motion off
Reduce Motion on
Button transition-duration
0.15s
0s
Button easing
cubic-bezier(0.22, 1, 0.36, 1)
same
animate-rise duration
0.24s
0s
Panel opacity 60 ms after click
0.75, still moving
1, already done
Panel 660 ms after click
opacity 1, no transform
opacity 1, no transform
The button's computed transition-property was transform, translate, scale, rotate, so transition-transform leaves layout alone. Neither run had horizontal scroll or console errors.
Keep the tokens few. Three durations and two easings are enough for most apps, and a short list is easier for an agent to follow.
Keep the example file real and current. If you change the tokens, update good-panel.tsx in the same commit.
When you ask for a new animated component, @-mention the rule or the example file in the prompt. Cursor's docs list manual @-mentions as one way to apply a rule.
Add the script to CI or a pre-commit hook. Then the rule can be imperfect without the app drifting.
Treat each flagged line as either a fix or a new token. If you keep needing a 600ms duration, add --motion-slower instead of allowing raw values.
Our pieces come with their own timings, so after you install one, map its durations onto your tokens or add its values as tokens. The hold-to-delete button and the animated tab bar are two that lean on motion, and the qodesign for Cursor page covers how to hand our files to the agent.
Project rules go in .cursor/rules as .mdc files with frontmatter. Cursor ignores a plain .md file in that folder. If you want plain markdown, use AGENTS.md instead.
Use globs for the folders that hold components, with alwaysApply: false, so the rule loads when the agent touches those files. Save alwaysApply: true for the few lines every request needs.
Put durations in CSS variables, set them to 0ms inside @media (prefers-reduced-motion: reduce), and use duration-(--your-variable) in your classes. In my test, the computed duration went from 0.15s to 0s with Reduce Motion on, and keyframe animations built on the same variable also dropped to 0s.