CSS animations feel like the safe choice. They are built into the browser, they are easy to write, and most of the time they do exactly what you expect.
But "CSS is faster than JavaScript" is a lazy rule.
The thing that matters most is the property you animate. Move an element with transform or fade it with opacity, and both CSS and JavaScript can be smooth. Animate height, left, margin, or a chunky box-shadow, and both can make Interaction to Next Paint and Cumulative Layout Shift worse.
This is the practical version: when CSS is enough, when JavaScript is the right tool, and which animation patterns quietly hurt Core Web Vitals.
The short answer
Use this as the quick rule:
- Simple hover, fade, slide, loading state or state transition: use CSS.
- Drag, scroll, cursor position, physics, interruption or complex sequencing: use JavaScript.
- Layout-changing animation: redesign it around
transformif you can. - Animation during page load: keep it tiny, or wait until the main content is visible.
- Animation during user input: profile it for INP on a real mobile device.
A CSS transition is not magic. A JavaScript animation is not automatically bad. The browser still has to do the same rendering work: style calculation, layout, paint, and compositing.
The fastest animation is the one that skips layout and paint.
How browsers render an animation
Every visual update can pass through four stages:
- Style: work out which CSS rules apply.
- Layout: calculate the size and position of elements.
- Paint: draw pixels for backgrounds, text, borders, shadows, and images.
- Composite: combine layers on screen, often on the GPU.
The expensive stages are layout and paint. They can affect large parts of the page, especially when one element's new size pushes other elements around.
The cheap stage is compositing. If the browser can move an existing layer without recalculating layout or repainting pixels, the animation has a much better chance of staying smooth.
That's why performance advice keeps coming back to the same two properties:
.card {
transition: transform 200ms ease, opacity 200ms ease;
}
.card:hover {
transform: translateY(-4px);
opacity: 0.96;
}
transform moves the element visually without changing its place in the document flow. opacity changes transparency without changing layout. Both are usually compositor-friendly.
The properties that usually perform well
These are the safest animation properties:
transform: translate(...)transform: scale(...)transform: rotate(...)opacity
Example: a performant slide-in panel:
.panel {
transform: translateX(100%);
transition: transform 250ms ease;
}
.panel[data-open="true"] {
transform: translateX(0);
}
The panel appears to move, but its layout box does not force the rest of the page to recalculate on every frame.
Compare that with this version:
.panel {
right: -320px;
transition: right 250ms ease;
}
.panel[data-open="true"] {
right: 0;
}
Animating right changes layout positioning. On a complex page, that can trigger repeated layout work and dropped frames.
The properties that often hurt performance
Be careful animating these:
widthheighttop,right,bottom,leftmargin,paddingfont-sizebox-shadowfilterborder-radiuson large elements- large gradients or background-position changes
They are not forbidden. A small height animation in an isolated menu might be fine. But these properties are much more likely to trigger layout or paint work.
Bad accordion pattern:
.answer {
height: 0;
overflow: hidden;
transition: height 250ms ease;
}
.answer[data-open="true"] {
height: 240px;
}
Better pattern:
.answer-inner {
transform: scaleY(0);
transform-origin: top;
opacity: 0;
transition: transform 200ms ease, opacity 200ms ease;
}
.answer[data-open="true"] .answer-inner {
transform: scaleY(1);
opacity: 1;
}
This avoids recalculating the height every frame. If the page layout really needs to expand, consider snapping the layout open first, then animating only the inner content with transform and opacity.
How animations affect INP
INP measures how quickly the page responds to user interactions. Animations hurt INP when they keep the main thread busy at the exact moment the user taps, clicks, or types.
Common INP problems:
- A menu animation runs expensive JavaScript on every frame.
- A carousel listens to scroll and recalculates layout constantly.
- A modal opens with a heavy blur/filter effect.
- A button click triggers both a network request and a layout-heavy animation.
- A React state update re-renders a large component tree during the animation.
The user does not care that the animation looks nice. They care that the tap feels instant.
A safer JavaScript animation loop looks like this:
let start;
const duration = 200;
const element = document.querySelector('.drawer');
function animate(timestamp) {
if (!start) start = timestamp;
const progress = Math.min((timestamp - start) / duration, 1);
// Only update transform. Do not read layout here.
element.style.transform = `translateX(${(1 - progress) * 100}%)`;
if (progress < 1) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
The key is not requestAnimationFrame by itself. The key is avoiding layout reads and layout writes inside the loop.
Avoid this pattern:
function animate() {
const height = element.offsetHeight; // layout read
element.style.height = `${height + 4}px`; // layout write
requestAnimationFrame(animate);
}
Reading layout and then writing layout every frame can cause forced synchronous layout. That is exactly the kind of main-thread work that makes interactions feel slow.
How animations affect CLS
CLS measures unexpected layout movement. Animations can hurt CLS when they push content around without the user causing it.
Examples:
- A banner slides in above the article after the page loads.
- A cookie notice appears and pushes the main content down.
- A carousel image loads without reserved dimensions.
- An expanding promo block changes height after fonts or scripts load.
This is a CLS problem:
.cookie-banner {
height: 0;
transition: height 300ms ease;
}
.cookie-banner[data-visible="true"] {
height: 96px;
}
It pushes the page down after the user started reading.
Better options:
.cookie-banner {
position: fixed;
left: 16px;
right: 16px;
bottom: 16px;
transform: translateY(120%);
transition: transform 250ms ease;
}
.cookie-banner[data-visible="true"] {
transform: translateY(0);
}
Or reserve the space from the beginning so the layout does not shift unexpectedly.
When CSS is the right tool
CSS is usually best for UI state transitions:
- Hover states
- Focus states
- Button feedback
- Dropdown fades
- Modal entrance/exit transitions
- Skeleton shimmer effects
- Simple loading indicators
- Reduced-motion fallbacks
Example button interaction:
.button {
transition: transform 120ms ease, opacity 120ms ease;
}
.button:hover {
transform: translateY(-1px);
}
.button:active {
transform: translateY(0) scale(0.98);
}
@media (prefers-reduced-motion: reduce) {
.button {
transition: none;
}
}
CSS keeps the interaction simple. It does not require a JavaScript runtime, state updates, or extra dependencies.
When JavaScript is the right tool
Use JavaScript when the animation depends on changing input:
- Drag-and-drop
- Swipe gestures
- Scroll-linked effects
- Physics or spring motion
- Interruptible animations
- Timeline sequencing
- Animations that depend on measured element size
- Canvas or WebGL effects
Example: a drag interaction needs JavaScript because every frame depends on pointer position.
const card = document.querySelector('.card');
let dragging = false;
card.addEventListener('pointerdown', () => {
dragging = true;
});
window.addEventListener('pointermove', (event) => {
if (!dragging) return;
card.style.transform = `translate(${event.clientX}px, ${event.clientY}px)`;
});
window.addEventListener('pointerup', () => {
dragging = false;
});
This can perform well because it still updates transform. JavaScript is only controlling the value.
If you use an animation library, the same rule applies. Framer Motion, GSAP, Motion One, and anime.js can all be fast or slow depending on what they animate.
Scroll animations: the common trap
Scroll animations are where teams accidentally create performance problems.
Bad pattern:
window.addEventListener('scroll', () => {
const top = window.scrollY;
hero.style.height = `${600 - top}px`;
sidebar.style.top = `${top * 0.2}px`;
});
This runs constantly during scroll and changes layout properties.
Better pattern:
let latestY = 0;
let ticking = false;
window.addEventListener('scroll', () => {
latestY = window.scrollY;
if (!ticking) {
requestAnimationFrame(() => {
hero.style.transform = `translateY(${latestY * 0.15}px)`;
ticking = false;
});
ticking = true;
}
}, { passive: true });
Even better: use CSS position: sticky, scroll-snap, or native CSS scroll-driven animations where browser support fits your audience.
The will-change mistake
You may have seen this advice:
.card {
will-change: transform;
}
will-change tells the browser an element is likely to change, so it can prepare optimizations ahead of time. It can help for short, important animations.
But do not add it everywhere.
Every promoted layer uses memory. Too many layers can make performance worse, especially on mobile. Use will-change only when:
- The element definitely animates soon.
- The animation is important.
- You remove it afterwards if it was added dynamically.
For most hover effects, you probably do not need it.
Accessibility: respect reduced motion
Performance is not the only reason to reduce animation. Some users experience motion sensitivity or vestibular discomfort.
Add a reduced motion fallback:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
For important transitions, consider replacing movement with a simple opacity change instead of removing all feedback.
@media (prefers-reduced-motion: reduce) {
.drawer {
transition: opacity 120ms ease;
transform: none;
}
}
How to test animation performance
Do not judge animation performance on your development laptop. Test it.
1. Record in Chrome DevTools
Open DevTools → Performance, then record the interaction.
Look for:
- Long tasks over 50ms
- Recalculate Style
- Layout
- Paint
- Dropped frames
- JavaScript running during the interaction
If every frame contains layout or paint work, the animation is not compositor-friendly.
2. Use the Rendering panel
In DevTools, open More tools → Rendering.
Enable:
- Paint flashing to see what repaints.
- Layout Shift Regions to catch unexpected movement.
- Frame rendering stats to spot dropped frames.
3. Test on mobile
A MacBook can hide problems that appear immediately on a mid-range Android phone. Use remote debugging or at least Lighthouse mobile throttling.
The question is not "does it feel smooth for me?" The question is "does it stay responsive for the slowest device I care about?"
Practical fixes for existing sites
If your site already has janky animations, start here:
Replace position animations with transform
/* Before */
.toast {
top: -80px;
transition: top 200ms ease;
}
.toast[data-visible="true"] {
top: 16px;
}
/* After */
.toast {
transform: translateY(-120%);
transition: transform 200ms ease;
}
.toast[data-visible="true"] {
transform: translateY(0);
}
Replace size animations with reveal patterns
Instead of animating height, use a wrapper:
.reveal {
overflow: clip;
}
.reveal-inner {
transform: translateY(-8px);
opacity: 0;
transition: transform 180ms ease, opacity 180ms ease;
}
.reveal[data-open="true"] .reveal-inner {
transform: translateY(0);
opacity: 1;
}
If the layout must expand, do it once, not every frame.
Delay non-critical entrance animations
Do not animate half the page while the browser is trying to render the Largest Contentful Paint element.
window.addEventListener('load', () => {
requestIdleCallback?.(() => {
document.documentElement.classList.add('enable-decorative-animations');
});
});
Decorative animation should not compete with the hero image, web fonts, or initial JavaScript.
Remove animation from frequently clicked controls
If a control is used repeatedly, make it instant. A fancy 300ms animation on every tab switch can make the interface feel slower even when the frame rate is fine.
For high-frequency UI, use tiny feedback:
.tab {
transition: background-color 80ms ease;
}
Not every interaction needs motion.
CSS vs JavaScript: the final rule
Before adding motion, ask:
- Does it only animate
transformoropacity? If not, can it? - Does it run during page load? If yes, can it wait?
- Does it respond to user input? If yes, profile INP.
- Does it move other content? If yes, check CLS.
- Does it respect
prefers-reduced-motion? - Does it still feel good on a mid-range phone?
CSS is the best default for simple state transitions. JavaScript is better for motion that is dynamic, interruptible or driven by input.
Neither is fast by default. Fast animations come from boring choices: the right properties, less layout work, and testing on the sort of phone your users actually have.
If your Core Web Vitals are already borderline, treat every animation as a budget decision. The smoothest animation is often the one users barely notice.
