Animations & Transitions (CSS + Framer Motion)
Why transform and opacity are the only cheap things to animate, how CSS transitions and @keyframes work, spring vs tween timing, and how Framer Motion adds exit/layout animations React can't do alone — plus honoring prefers-reduced-motion.
The big picture (read this first). Animation is just changing a value over time and letting the browser paint the in-between frames — ideally 60 of them per second, one every ~16.7ms. Everything about doing it well reduces to two questions: (1) what property are you animating (which decides whether the browser can do it cheaply on the GPU or has to redo expensive layout every frame), and (2) how does the value change over time (the timing — easing curves vs. physics springs). Get the property right and almost anything is smooth; get it wrong and even a tiny animation janks.
The cost ladder — the one rule that matters. Browser rendering runs layout -> paint -> composite. Animating transform (translate/scale/rotate) and opacity touches only the composite stage: the element is already on its own layer, so the GPU just re-transforms or re-blends the existing bitmap — no layout, no paint, buttery 60fps even while the main thread is busy. Animating geometry (width, height, top, left, margin, padding) forces reflow every frame: recompute layout, repaint, recomposite — which cascades to other elements and janks. Animating color/background is in between (repaint, no reflow). The golden rule: animate transform and opacity, never top/left/width.
CSS transitions — animate a state change. A transition interpolates a property when its value changes (usually on :hover, :focus, or a toggled class). You declare transition: transform 0.25s ease, opacity 0.25s ease and the browser animates whenever those properties change. Transitions are perfect for enter/leave-on-toggle and micro-interactions because they're declarative, need no JS, and stay on the compositor when you transition transform/opacity. You can't loop them or define intermediate steps — for that you need keyframes.
CSS keyframes — animate a timeline. @keyframes define named waypoints (0%, 50%, 100%) and animation: pulse 1.2s ease-in-out infinite plays them, with control over duration, iteration count, direction, delay, and fill mode. Keyframes handle looping, multi-step, and self-starting animations (spinners, pulses, attention cues) that transitions can't. Same performance rule applies: keep the animated properties to transform/opacity.
Easing: the shape of time. Easing maps linear time to a progress curve so motion feels natural rather than robotic. linear is constant speed (mechanical); ease-in accelerates (good for exits); ease-out decelerates (good for entrances — feels responsive); ease-in-out does both. cubic-bezier(...) defines a custom curve. As a default, ease-out for elements entering feels snappy because it starts fast and settles gently.
Tween vs spring — two models of motion. A tween animates over a fixed duration along an easing curve (300ms ease-out): predictable and repeatable, best for UI transitions where you want consistent timing. A spring is physics-based — you specify stiffness, damping, and mass, and the motion has no fixed duration; it settles naturally and can subtly overshoot, which feels organic and interruptible. Springs shine for gestures and draggable/interactive elements because when the target changes mid-flight they respond continuously from the current velocity, whereas a tween would restart. Framer Motion supports both; pick tween for deterministic UI and spring for tactile, interruptible motion.
Framer Motion — declarative animation for React. You swap an element for its motion.* counterpart (motion.div) and describe states as props: initial (start), animate (end), transition (timing/type). An entrance is just initial={{ opacity: 0, y: 20 }} -> animate={{ opacity: 1, y: 0 }}. Its x/y/scale/rotate shorthands map to transform, so it stays on the cheap composite path by design. variants let you name reusable state sets and orchestrate children with staggerChildren. It's the declarative, state-driven way to do what you'd otherwise wire up imperatively.
Exit animations — the thing React can't do alone. React removes a component from the DOM the instant it unmounts, so there's no chance to animate it out. Wrapping elements in <AnimatePresence> defers the actual unmount until the element's exit animation finishes — enabling leave animations for list removals and route changes. It's exactly like a graceful-shutdown drain: instead of instantly killing the component, you let in-flight work (the animation) complete first.
Layout animations and FLIP. The layout prop makes an element smoothly animate to its new position/size when the layout changes (a list reorders, a card expands). Under the hood it uses FLIP — First, Last, Invert, Play: measure the start and end box, apply an inverse transform so the element looks unmoved, then animate that transform to zero. Because FLIP animates transform (not width/top), the reorder stays on the compositor and smooth. This is how you animate layout changes without paying the reflow-every-frame cost.
Page transitions. Combine the two: wrap routed content in <AnimatePresence mode="wait"> so the outgoing page finishes its exit before the incoming one enters, and key a motion.div by the current path so Framer Motion treats each route as a distinct element to animate in and out. Give it initial/animate/exit (often a simple opacity or slide) and a short transition.
Accessibility — honor prefers-reduced-motion. Vestibular disorders make large motion literally nauseating, so the OS exposes a "reduce motion" setting. Respect it: in CSS, wrap non-essential animation in @media (prefers-reduced-motion: reduce) and disable or shorten it; in Framer Motion, use the useReducedMotion() hook to swap big movement for a simple fade or none. Don't remove all feedback — keep subtle opacity changes — just kill the large parallax, spins, and slides. This is a real accessibility requirement, not a nicety.
Practical guardrails. Prefer CSS transitions/keyframes for simple, self-contained micro-interactions (they need no JS and stay on the compositor) and reach for Framer Motion when you need exit animations, layout/FLIP, orchestration, or gesture-driven springs. Keep animations short (150–350ms for UI) — long animations feel sluggish. Avoid animating many elements at once, use will-change sparingly (it promotes a layer but overuse wastes memory), and always test on a mid-range device, not just your fast laptop.
The mental model (memorise this). Animate transform and opacity only — they ride the GPU compositor and skip layout/paint, so they hold 60fps; geometry properties force reflow every frame and jank. CSS transitions animate a state change and keyframes animate a looping timeline; tweens use a fixed-duration easing curve while springs use interruptible physics. Framer Motion adds what React can't: AnimatePresence for exit animations (a graceful drain before unmount) and layout/FLIP for smooth reorders — and always honor prefers-reduced-motion.
The cost ladder is the same tiered-cost thinking you use for datastore writes: a transform/opacity change is a cache flip or feature-flag toggle (near-free, no rebuild), a repaint is a hot-swap of one class, and a geometry change that forces reflow is a full recompile-and-redeploy that cascades through dependents. AnimatePresence is a graceful-shutdown hook: React normally kills a component instantly on unmount, but AnimatePresence defers removal until the exit animation completes — exactly how a server drains in-flight requests before shutting down instead of dropping connections. Spring vs tween is control theory vs a fixed schedule: a spring is a PID-style controller that continuously converges on a moving target from its current state (interruptible), while a tween is a fixed cron-like schedule that must restart if the target changes. Honoring prefers-reduced-motion is reading a client capability/preference header and degrading the response accordingly.
- Animate transform (translate/scale/rotate) and opacity only — they touch just the composite stage, run on the GPU, and hold 60fps.
- Animating geometry (width/height/top/left/margin) forces reflow every frame, cascades to other elements, and janks; color/background is a cheaper repaint but still not composite-only.
- CSS transitions animate a state change (hover/focus/toggled class); @keyframes animate a looping, multi-step, self-starting timeline.
- Easing shapes time: ease-out (decelerate) feels responsive for entrances, ease-in for exits, cubic-bezier for custom curves.
- Tween = fixed-duration easing curve, deterministic UI transitions; spring = physics (stiffness/damping/mass), no fixed duration, interruptible — best for gestures and draggable elements.
- Framer Motion: motion.* elements with initial/animate/transition; x/y/scale shorthands map to transform so it stays on the cheap path by design.
- AnimatePresence defers unmount until the exit animation finishes — the only way to animate elements leaving in React (list removals, route changes).
- The layout prop animates position/size changes via FLIP (First/Last/Invert/Play) using transforms, so reorders stay smooth and compositor-driven.
- Page transitions: AnimatePresence mode="wait" + a motion.div keyed by the route path so old exits before new enters.
- Honor prefers-reduced-motion (CSS media query or useReducedMotion()) — reduce large motion to a fade/none; it's an accessibility requirement, not optional polish.
Worked Code
/* TRANSITION: interpolate a property when its value changes (on hover).
Transitioning transform + opacity stays on the compositor -> 60fps. */
.card {
opacity: 0.9;
/* declare WHAT animates and HOW LONG/curve */
transition: transform 0.25s ease-out, opacity 0.25s ease-out;
}
.card:hover {
transform: translateY(-6px) scale(1.03); /* GPU transform, no reflow */
opacity: 1;
}
/* KEYFRAMES: a looping, multi-step, self-starting timeline. */
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.12); opacity: 0.6; }
}
.badge {
animation: pulse 1.2s ease-in-out infinite;
}
/* ACCESSIBILITY: honor the user's reduce-motion preference. */
@media (prefers-reduced-motion: reduce) {
.card { transition: opacity 0.15s ease; } /* drop the movement */
.card:hover { transform: none; }
.badge { animation: none; }
}import { motion, AnimatePresence, type Variants } from "framer-motion";
// Named variants make states reusable and let a parent orchestrate children.
const listVariants: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.06 } }, // cascade children in
};
const itemVariants: Variants = {
hidden: { opacity: 0, x: -20 }, // x/y/scale map to transform (cheap)
show: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 20 },
};
export function ExpenseList({ items }: { items: { id: string; label: string }[] }) {
return (
<motion.ul variants={listVariants} initial="hidden" animate="show">
{/* AnimatePresence defers unmount so 'exit' can play on removal */}
<AnimatePresence>
{items.map((item) => (
<motion.li
key={item.id}
variants={itemVariants}
exit="exit"
layout // FLIP: smooth reorder via transforms
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
{item.label}
</motion.li>
))}
</AnimatePresence>
</motion.ul>
);
}import { motion, useReducedMotion } from "framer-motion";
export function Panel({ open }: { open: boolean }) {
const reduceMotion = useReducedMotion(); // reads OS reduce-motion setting
return (
<motion.div
animate={{ x: open ? 0 : -300, opacity: open ? 1 : 0 }}
transition={
reduceMotion
// reduced motion: instant/short fade, no big movement
? { duration: 0.15 }
// SPRING: physics, no fixed duration, interruptible mid-flight.
// Great for gestures because it continues from current velocity.
: { type: "spring", stiffness: 260, damping: 24 }
}
>
Sliding panel
</motion.div>
);
}
// TWEEN alternative: fixed duration + easing curve — deterministic UI.
// transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}import { motion, AnimatePresence } from "framer-motion";
import { Routes, Route, useLocation } from "react-router-dom";
export function AnimatedRoutes() {
const location = useLocation();
return (
// mode="wait": outgoing page finishes exit BEFORE incoming enters
<AnimatePresence mode="wait">
<motion.div
// keying by path makes each route a distinct element to animate
key={location.pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<Routes location={location}>
<Route path="/" element={<Dashboard />} />
<Route path="/expenses" element={<Expenses />} />
</Routes>
</motion.div>
</AnimatePresence>
);
}▶Try It Live
Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.
Interview-Ready Q&A
Browser rendering is layout -> paint -> composite. transform (translate/scale/rotate) and opacity touch only the composite stage: the element is on its own layer, so the GPU just re-transforms or re-blends the existing bitmap — no layout, no paint — so it holds 60fps even while the main thread is busy. Animating width, height, top, left, or margin forces a reflow every frame (recompute layout, repaint, recomposite), which cascades to other elements and janks. That's why Framer Motion's x/y/scale shorthands map to transforms and why FLIP animates transform rather than geometry.
- 1Animate transform + opacity only — composite stage, GPU, 60fps; geometry forces reflow every frame and janks.
- 2CSS transition = animate a state change (hover/toggle); @keyframes = looping, multi-step, self-starting timeline.
- 3ease-out for entrances (feels responsive), ease-in for exits, cubic-bezier for custom curves.
- 4Tween = fixed-duration easing (deterministic UI); spring = physics, interruptible, best for gestures/drag.
- 5Framer Motion motion.* + initial/animate/transition; x/y/scale map to transform by design.
- 6AnimatePresence defers unmount so exit animations can play — the only way to animate elements leaving in React.
- 7layout prop animates reorders via FLIP (First/Last/Invert/Play) using transforms.
- 8Page transitions: AnimatePresence mode="wait" + motion.div keyed by route path.
- 9Honor prefers-reduced-motion (CSS media query or useReducedMotion()) — reduce big motion to a fade/none.
- 10Keep UI animations short (150-350ms), avoid animating many elements at once, and test on a mid-range device.