Animations make interfaces feel alive. But done wrong, they make your page feel sluggish and drain mobile batteries. I've shipped a lot of animated UIs — from the star field on this portfolio to full-page transitions on client sites — and I've learned the hard way what to do and what to avoid.
The rule is simple: animate only transform and opacity. Everything else is expensive.
How The Browser Renders
To understand why, you need to understand the browser's rendering pipeline. When you change a CSS property, one of three things happens:
- Layout (reflow) — the browser recalculates how much space elements take up. Changing
width,height,padding,margin,top,lefttriggers this. It's expensive and blocks the main thread. - Paint (repaint) — the browser redraws pixels. Changing
background-color,border-color,box-shadowtriggers this. Cheaper than layout, but still main thread. - Composite only — the GPU handles it without touching the main thread. Only
transformandopacityqualify. This is where 60fps lives.
When you animate a property that triggers layout or paint, the browser has to redo that work every frame (60 times per second). The main thread gets blocked and your animation stutters.
The Golden Rules
Use transform, not top/left
The bad way to move something:
/* BAD — triggers layout every frame */
.card:hover {
top: -4px;
left: 0;
}
The right way:
/* GOOD — composited, no layout */
.card {
transition: transform 0.2s ease;
}
.card:hover {
transform: translateY(-4px);
}
Same visual result. Completely different performance profile.
Use will-change Sparingly
will-change: transform hints to the browser that an element is about to animate, so it can promote it to its own compositor layer early. This makes the first frame of the animation instant instead of janky.
.animated-card {
will-change: transform;
}
But don't slap it on everything. Each compositor layer uses GPU memory. Overuse it and you'll run out of memory on low-end devices — your animation will degrade worse than if you hadn't used it at all. Apply it only to elements that are actively animating, and remove it after the animation completes if you're using JS.
Entrance Animations
Scroll-triggered entrance animations are everywhere. Done right they're elegant. Done wrong they're the reason Lighthouse tanks your CLS score.
The correct pattern using IntersectionObserver:
/* CSS */
.fade-in {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.5s ease, transform 0.5s ease;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
// JS
const observer = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
e.target.classList.add('visible')
observer.unobserve(e.target) // animate once
}
})
}, { threshold: 0.1 })
document.querySelectorAll('.fade-in')
.forEach(el => observer.observe(el))
No scroll event listeners. No getBoundingClientRect in a scroll handler. IntersectionObserver runs off the main thread and is exactly designed for this.
Skeleton Loaders
Skeleton loading animations (the shimmer effect) are another common performance trap. The cheap implementation uses an animated background gradient:
/* BAD — animates background, triggers paint */
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, #eee 25%, #ddd 50%, #eee 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
The performant version uses a pseudo-element with transform:
/* GOOD — composited shimmer */
.skeleton {
position: relative;
overflow: hidden;
background: #1a1a2e;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent);
transform: translateX(-100%);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
to { transform: translateX(100%); }
}
The gradient itself never changes — only the transform animates, so it's fully composited.
Checking Your Work
Chrome DevTools has a built-in way to verify you're not triggering layout or paint:
- Open DevTools → Performance tab
- Click the paint icon to enable "Paint flashing"
- Trigger your animation
- Green overlays = paint is happening = bad
You can also use the "Rendering" panel → "Layer borders" to see which elements have been promoted to their own compositor layer.
Every animation on this portfolio — the hover effects, the scroll reveals, the cursor — uses onlytransformandopacity. Open DevTools and check: no paint flashing anywhere.