React: Transitions and Animations
Last updated: 2026-08-26
Tom had just finished a data dashboard project, but the product manager pointed out that the page transitions were too abrupt—the data appeared with a “flash” when the list loaded, and pop-ups opened and closed instantly. Tom realized that having all the features doesn’t necessarily mean a good user experience. He needed to add smooth transition animations to the pages.
1. What You'll Learn
- Basic Usage and Differences Between CSS Transitions and Animations
- Framer Motion's motion component and AnimatePresence
- Mount/unmount animations
- Batch animation of list items (layout animation)
- Gesture-based interactive animations (drag, hover)
2. Conceptual Diagrams
The following diagram will help you make quick decisions when choosing an animation option:
flowchart TD
A[Animation effects are needed] --> B{Effect Complexity}
B -->|Simple Transition| C[CSS Transition]
B -->|Keyframe Animation| D[CSS Animation]
B -->|Complex Interactions| E[Framer Motion]
C --> C1["hover / focus Effect"]
C --> C2["opacity / transform Fade Animation"]
C --> C3["Changes to a Single Attribute"]
D --> D1["@keyframes Defining Multi-Stage"]
D --> D2["Infinite Loop Animation"]
D --> D3["Loading the skeleton screen"]
E --> E1["mount/unmount Animation"]
E --> E2["Drag / Gestures"]
E --> E3["List layout Animation"]
E --> E4["Spring Physical Animation Effects"]
style C fill:#e3f2fd,stroke:#1565c0
style D fill:#e8f5e9,stroke:#2e7d32
style E fill:#fff3e0,stroke:#e65100
3. A Real-Life Scenario
Tom's dashboard includes three core interactions: task list loading, modal window toggling, and card dragging and sorting. His goal:
- When the task list loads, each task slides in from the left.
- When the pop-up opens, the background fades in and the pop-up zooms in; when it closes, the animation runs in reverse.
- The card follows your finger when you drag it and snaps back into place when you let go.
He started with the simplest CSS transitions and gradually moved on to creating complex animations using Framer Motion.
(1) CSS Transitions — The Simplest Progressive Enhancement
CSS Transitions are used to create smooth transitions between the initial state and the final state. They do not require any JavaScript libraries and take effect simply by declaring them in CSS.
The Four Core Subproperties of Transition
/* transition: property duration timing-function delay */
.example {
transition: opacity 0.3s ease 0s;
/* Complete Syntax:
transition-property: opacity
transition-duration: 0.3s
transition-timing-function: ease
transition-delay: 0s
*/
}
Common timing-function values:
ease: Slow → Fast → Slow (default)ease-in: Slow → Constant speedease-out: Constant speed → Slowease-in-out: Slow at both ends, fast in the middlelinear: Constant speed throughoutcubic-bezier(n, n, n, n): Custom Bézier Curves
▶ Example 1: Implementing an Accordion Component Using CSS Transitions
Output:
Styled components with CSS-in-JS or scoped styles. Animated transitions: elements fade/slide/move smoothly
import { useState } from 'react'
const accordionData = [
{ title: 'What is React?', content: 'React It is a tool for building user interfaces. JavaScript library.' },
{ title: 'What Is a Component??', content: 'A component is React The smallest independent unit of an application,Reusable and combinable。' },
{ title: 'What is State?', content: 'State It is variable data within the component,Driver UI Update。' },
]
function Accordion() {
const [openIndex, setOpenIndex] = useState(null)
function toggle(index) {
setOpenIndex(openIndex === index ? null : index)
}
return (
<div style={{ maxWidth: 500, fontFamily: 'sans-serif' }}>
{accordionData.map((item, index) => (
<div
key={index}
style={{
border: '1px solid #e8e8e8',
borderRadius: 8,
marginBottom: 8,
overflow: 'hidden',
}}
>
<button
onClick={() => toggle(index)}
style={{
width: '100%',
padding: '12px 16px',
background: openIndex === index ? '#e6f7ff' : '#fafafa',
border: 'none',
cursor: 'pointer',
fontSize: 15,
fontWeight: 600,
textAlign: 'left',
transition: 'background 0.2s ease',
}}
>
{item.title}
</button>
<div
style={{
maxHeight: openIndex === index ? 80 : 0,
padding: openIndex === index ? '12px 16px' : '0 16px',
opacity: openIndex === index ? 1 : 0,
transition: 'all 0.3s ease',
color: '#666',
fontSize: 14,
lineHeight: 1.6,
}}
>
{item.content}
</div>
</div>
))}
</div>
)
}
Output:
Fade in/out: opacity 0→1 over 300ms. Toggle button shows/hides element with smooth CSS transition.
Key Point: maxHeight + opacity + padding transition simultaneously to achieve the expand/collapse effect for the content area. Note that the value of maxHeight should be slightly greater than the actual content height.
(2) CSS Animation — Keyframe-Driven Animation
When an animation has more than two stages (beyond just start → end) or needs to loop, CSS Animation is the better choice. It uses @keyframes to define multi-stage animation sequences.
Core Attributes
/* animation: name duration timing-function delay iteration-count direction fill-mode */
.pulse {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.7; }
100% { transform: scale(1); opacity: 1; }
}
▶ Example 2: Skeleton Screen Load Animation
Output:
Skeleton screen: gray animated placeholders while loading → replaced by real content on fetch complete
import './Skeleton.css'
function Skeleton({ count = 3 }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="skeleton-row">
<div className="skeleton-avatar" />
<div style={{ flex: 1 }}>
<div className="skeleton-line" style={{ width: '60%' }} />
<div className="skeleton-line" style={{ width: '90%', marginTop: 8 }} />
</div>
</div>
))}
</div>
)
}
export default Skeleton
Output:
Skeleton screen: gray placeholder blocks pulse/shimmer while data loads → replaces with real content when fetch completes
/* Skeleton.css */
.skeleton-row {
display: flex;
gap: 12px;
align-items: center;
}
.skeleton-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.skeleton-line {
height: 14px;
border-radius: 4px;
background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
How Skeleton Screen Animations Work: The movement of background-position simulates the visual effect of a light trail sweeping across the screen during loading. The infinite property in CSS Animation causes the light trail to loop continuously until the actual content finishes loading and replaces the skeleton screen.
(3) Framer Motion — Complex React Animations
Framer Motion is the most powerful animation library in the React ecosystem, offering three core capabilities:
| Capability | Problem Solved | Corresponding API |
|---|---|---|
| Enter/Exit Animations | Animations When Components Are Mounted/Unmounted | motion.div + AnimatePresence |
| Layout Animation | Smooth Transitions When List Order Changes | layout prop |
| Gesture Animations | Drag, Hover, and Tap Gestures | drag / whileHover / whileTap |
Installation
npm install framer-motion
▶ Example 3: Implementing a To-Do List with Framer Motion
Output:
Todo list with Framer Motion: animated add/remove/reorder. Click to toggle done (strikethrough). Drag to reorder items
import { useState } from 'react'
import { motion, AnimatePresence, Reorder } from 'framer-motion'
const initialTodos = [
{ id: 1, text: 'Done React Learning Animation', done: false },
{ id: 2, text: 'Reworking the Dashboard Components', done: false },
{ id: 3, text: 'Code Review PR #142', done: false },
{ id: 4, text: 'Update Project Documentation', done: true },
{ id: 5, text: 'Fix the Responsive Navigation BarBUG', done: false },
]
function TodoList() {
const [todos, setTodos] = useState(initialTodos)
function toggleDone(id) {
setTodos(prev =>
prev.map(t => (t.id === id ? { ...t, done: !t.done } : t))
)
}
function removeTodo(id) {
setTodos(prev => prev.filter(t => t.id !== id))
}
return (
<div style={{ maxWidth: 480, fontFamily: 'sans-serif' }}>
<AnimatePresence>
{todos.map(todo => (
<motion.div
key={todo.id}
layout
initial={{ opacity: 0, x: -60 }}
animate={{
opacity: 1,
x: 0,
background: todo.done ? '#f6ffed' : '#fff',
}}
exit={{ opacity: 0, x: 100, height: 0, marginBottom: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
marginBottom: 8,
borderRadius: 8,
border: '1px solid #f0f0f0',
cursor: 'pointer',
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleDone(todo.id)}
style={{ width: 18, height: 18, cursor: 'pointer' }}
/>
<span
style={{
textDecoration: todo.done ? 'line-through' : 'none',
color: todo.done ? '#999' : '#333',
fontSize: 15,
}}
>
{todo.text}
</span>
</div>
<button
onClick={() => removeTodo(todo.id)}
style={{
border: 'none',
background: 'none',
color: '#ff4d4f',
cursor: 'pointer',
fontSize: 18,
fontWeight: 'bold',
}}
>
x
</button>
</motion.div>
))}
</AnimatePresence>
</div>
)
}
export default TodoList
Output:
Fade in/out: opacity 0→1 over 300ms. Toggle button shows/hides element with smooth CSS transition.
This code demonstrates four key features of Framer Motion:
initial/animate/exit: Define the component’s entry, display, and exit states.AnimatePresenceallows conditional rendering to trigger the exit animation as well.layoutprop: When a list item changes position due to a change in thedonestate, it automatically transitions smoothly to the new position without the need to calculate coordinates.transition: Uses thespringphysical spring animation;stiffnesscontrols spring stiffness;dampingcontrols damping (the higher the value, the less springy it is)whileHover/whileTap: Interactive gesture animation; zooms in to 1.02x when the mouse hovers over it, and zooms out to 0.98x when clicked
▶ Example 4: Framer Motion Modal Pop-up Animation
Output:
Framer Motion: animated enter/exit/layout transitions. AnimatePresence for mount/unmount animations
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
function Modal({ isOpen, onClose, children }) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.45)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
onClick={onClose}
>
<motion.div
key="modal"
initial={{ scale: 0.85, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.85, opacity: 0, y: 20 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
background: '#fff',
padding: 24,
borderRadius: 12,
minWidth: 320,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
position: 'relative',
}}
onClick={e => e.stopPropagation()}
>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
function App() {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button
onClick={() => setIsOpen(true)}
style={{ padding: '8px 20px', fontSize: 15, cursor: 'pointer' }}
>
Open the pop-up window
</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<h2 style={{ margin: '0 0 12px' }}>Confirm Action</h2>
<p style={{ color: '#666', marginBottom: 20 }}>
Are you sure you want to delete this record??This action cannot be undone.。
</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button
onClick={() => setIsOpen(false)}
style={{ padding: '8px 20px', cursor: 'pointer' }}
>
Cancel
</button>
<button
onClick={() => {
alert('Deleted')
setIsOpen(false)
}}
style={{
padding: '8px 20px',
background: '#ff4d4f',
color: '#fff',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
}}
>
Confirm Deletion
</button>
</div>
</Modal>
</div>
)
}
Output:
Fade in/out: opacity 0→1 over 300ms. Toggle button shows/hides element with smooth CSS transition.
Key Points for Designing Pop-up Animations:
- Overlays and modals each use a different set of animations, but they are managed uniformly using
AnimatePresence - Use the
springanimation for pop-ups,stiffness: 400to make them respond quickly, anddamping: 30to reduce bouncing - On entry: The pop-up enters from 0.85x scale + 20px below
- On exit: reverse zoom + move down, mirroring the entry animation
4. Best Practices for Animation Performance
| Attribute | Trigger Phase | Performance | Recommendation |
|---|---|---|---|
transform |
Composite | ✅ Best | ⭐⭐⭐ |
opacity |
Composite | ✅ Best | ⭐⭐⭐ |
filter |
Paint | ⚠️ Medium | ⭐⭐ |
box-shadow |
Paint | ⚠️ Medium | ⭐ |
width/height |
Layout | ❌ Expensive | ❌ |
top/left/margin |
Layout | ❌ Expensive | ❌ |
| Rule | Description |
|---|---|
Use transform and opacity by default |
These properties trigger the compositor thread without causing a Layout/Paint |
Avoid animations width / height / top / left |
These properties trigger a layout reflow, which is resource-intensive |
Use will-change to prompt the browser |
will-change: transform, opacity to create a composite layer in advance |
| Avoid animating too many elements at once | Animating more than 20 elements simultaneously may cause frame drops |
Using requestAnimationFrame |
Avoiding the Use of setTimeout in JavaScript-Driven Animations |
▶ Example 5: CSS Keyframe Animation—Loading Skeleton Screen
Output:
Skeleton screen: gray animated placeholders while loading → replaced by real content on fetch complete
function SkeletonCard() {
return (
<div style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 16, overflow: 'hidden' }}>
<div style={{
height: 120, borderRadius: 4, marginBottom: 12,
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
}} />
<div style={{
height: 16, width: '60%', borderRadius: 4, marginBottom: 8,
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
}} />
<div style={{
height: 12, width: '40%', borderRadius: 4,
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
}} />
<style>{`
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
)
}
function LoadingGrid() {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, maxWidth: 800, margin: '0 auto' }}>
{[1, 2, 3].map(i => <SkeletonCard key={i} />)}
</div>
)
}
Output:
Infinite loop: setState in useEffect without deps → render → effect → setState → render... Fix: add deps or use empty array [].
❓ FAQ
AnimatePresence in Framer Motion sometimes not work?key is not set up correctly. AnimatePresence tracks child elements via key; if the key remains unchanged, it interprets the element as "updated" rather than "removed," and the exit animation will not trigger. Additionally, AnimatePresence’s direct child elements must be motion components.transform and opacity animations, as these properties run on the mobile GPU compositing layer. Framer Motion uses transform by default, which ensures good performance. If you must create complex animations on mobile devices, you can use the useWillChange hook or manually set will-change.layout prop can be used in combination with layoutId and AnimatePresence to achieve a stable layout transition. If using CSS, consider using transform: translateX() instead of left for position animations.useTransition and Framer Motion?useTransition is a built-in concurrency feature in React that marks certain state updates as “non-urgent” (such as filtering search results), allowing urgent updates (such as typing in an input field) to be processed first to prevent stuttering. Framer Motion is an animation library that handles visual effects such as movement, scaling, and opacity for UI elements. The two address different issues: useTransition addresses “interaction lag,” while Framer Motion addresses “visual transitions.”📖 Summary
- CSS Transitions are suitable for simple two-stage transitions that trigger automatically when a state changes.
- CSS Animation, when used with
@keyframes, can create complex, multi-stage, looping animations. - The
motionandAnimatePresencecomponents in Framer Motion cover all animation scenarios for React components - The three properties
initial,animate, andexitcontrol the enter, display, and exit states, respectively. layoutThepropenables smooth transitions when reordering lists, without the need to manually calculate coordinates- Core principles of animation performance: Always prioritize
transformandopacity
📝 Exercises
- Use CSS Transitions to implement a "Back to Top" button: It fades in when the page is scrolled more than 300px; when clicked, it smoothly scrolls to the top; and it fades out and hides once it reaches the top.
- Use CSS Animation to create a loading spinner: a rotating circle animation. Use
@keyframesto control the rotation from 0deg to 360deg, and uselinearfor the speed curve to maintain a constant speed. - Use Framer Motion to create a draggable floating action button (FAB). The
dragprop allows it to be dragged anywhere on the screen, and when dragging ends, use thespringanimation to snap it to the nearest screen edge.