Animations on the Web
Every time I land on a site with a smooth, spring-y animation, something that feels native, almost like a phone app, I open DevTools and slow it down. Frame by frame. Just to see what they did.
For a long time it felt like magic. A button shifts, a dropdown fades in, a modal slides up. You don't actually think about any of it, it just feels right.
They play a big role in creating a delightful experience throughout a user's journey. I had no idea there was math behind it, a GPU that might be involved, and a developer making choices, often without fully knowing why one approach felt smooth and another didn't.
Then I started actually building them. Reached for a CSS transition, it didn't work the way I expected, switched to keyframes, hit a different wall. Used Motion, watched it lag under heavy computation, had to dig into why. That's when the magic started making sense.
In modern web development, we have different tools and APIs to animate things. Each works differently, each has its own ups and downs. Let's go through some of them.
CSS Transitions
The simplest way to animate on the web is using CSS transitions. You tell the browser: when this property changes, don't snap to the new value, take some time to get there.
Every element on a page has state. A dropdown is open or closed. A button is hovered or not. A modal is visible or hidden.
By default, CSS cuts between these states instantly. Transitions let you interpolate between them, turning a hard cut into a smooth change.
CSS transitions work by animating the change of state between two points. The browser figures out all the frames in between.
You control how it gets there through a few properties: how long it takes, what easing curve it follows, and which properties to animate. Play with the easing and duration dropdowns above to see how much it changes the feel.
Easing didn't click for me at first. It just seemed like a subtle tweak. But once I started actually playing with different curves, feeling how ease-in-out gives an animation a quick start versus how linear feels mechanical and robotic, my whole perspective shifted. The same motion, completely different personality.
A mistake I see a lot is transition: all 0.3s ease. It works, but it's lazy.
Every property gets the same timing and the same curve. Color, shadow, and scale each feel different. A color shift at 150ms ease-out feels snappy. A shadow at 200ms feels weightier, more physical. Scale on press wants asymmetric timing, fast down and slightly slower back up.
.button {
transition:
background-color 150ms ease-out,
box-shadow 200ms ease-out,
transform 100ms ease-out;
}
.button:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
.button:active { transform: scale(0.97); }Most CSS transitions are event-driven though. A hover, a focus, a class toggled by JavaScript. Something has to cause the change.
And they only work between two states, no looping or sequencing. A few things don't work at all either. display: none and height: auto are the two you'll hit most often.
I spent way too long trying to figure out why height: auto wouldn't animate. The element knows its new height, why can't it just go there? Turns out the browser can't interpolate to an unknown value. That's usually where people reach for keyframes.
CSS Keyframes
Transitions are great, but you can't control what happens between the two states. The browser figures out the in-between and that's it. When you need more than that, keyframes are the answer.
CSS keyframe animations let you define multiple states across a timeline. Instead of just A to B, you get A to B to C to D and back. You place checkpoints wherever you want, and the browser fills in everything between them.
You define these checkpoints with the @keyframes rule, give the animation a name, then attach it to an element. You can change multiple CSS properties at each checkpoint, which is what makes them suitable for complex motion that can't be described by just a start and end state.
They're especially good for things that loop on their own. Loaders, pulsing indicators, ambient effects.
I've used them for custom loader animations, set iteration count to infinite and it just runs. No event needed, no state to manage. You can also control direction, play it forward, then reverse, then alternate.
But there's a fundamental difference from transitions. Keyframes run on a fixed timeline. They don't respond to state changes mid-animation.
A lot of people hit this wall and immediately reach for Motion. And that's not wrong.
But if all you need is a couple of in-between frames or a simple multi-step entrance, keyframes will do it in pure CSS with no extra dependency. Motion is the right call when you need interruptibility or spring physics. Keyframes are the right call when the animation just needs to play through.
Transitions interpolate toward the latest state. They can be interrupted and will reverse from wherever they currently are. Keyframes don't do this. Once started, they run their course.
The accordion above is something you'll build in almost every app. Toggle one panel rapidly and watch what happens on each side.
The keyframe one snaps back. Frustrating, because from the user's side they clicked to open it, but now it has to finish closing first before it opens again.
The problem is keyframes work on a fixed timeline. They aren't tied to property changes, they ignore them. If you change state mid-animation, the animation doesn't care. It keeps following its defined path, overriding whatever the current value is, and restarts from the top when triggered again.
Transitions don't have this problem because they're always interpolating toward the current state. Wherever the element is, that's the new starting point.
This is why keyframes work best for animations that play through completely on their own. Entrance animations, loaders, decorative motion. Anything that has no reason to reverse mid-way.
Web Animations API
CSS gets you far, but it has a hard limit: once an animation is running, you can't control it from JavaScript. You can't pause it, seek through it, change its speed, or chain it with another animation after it finishes. It just plays.
I ran into this building a timeline animation that needed to be interruptible. I didn't want to pull in Motion for it, so I started digging for a native way to do it. That's when I found the Web Animations API.
You call .animate() on any DOM element, pass it keyframes and options, and it returns an Animation object.
const anim = element.animate(
[{ opacity: 0, transform: 'translateY(10px)' },
{ opacity: 1, transform: 'translateY(0px)' }],
{ duration: 400, easing: 'ease-out', fill: 'forwards' }
);That object is where it gets interesting. You can pause and resume it, reverse it, change its playback speed, or seek to any point in time with currentTime.
The .finished promise is the one I find myself reaching for the most. It resolves when the animation completes, so you can chain things without hacking setTimeout.
anim.playbackRate = 2; // double speed
anim.pause(); // pause mid-animation
anim.currentTime = 200; // seek to 200ms
anim.finished.then(() => nextAnimation.play()); // chainFor interruptible animations, the trick is getComputedStyle. It gives you the element's actual computed values mid-animation, so when you start the next animation you're starting from wherever it actually is, not snapping back to the original position. That's the part that took me a while to figure out.
None of that is possible with CSS alone. You'd need JavaScript to toggle classes, hack timing with setTimeout, and lose control the moment the animation starts.
Hit play and watch the dot move. Pause it mid-way, drag the scrubber, change the speed. Every one of those controls is talking directly to the Animation object.
WAAPI is underrated. Most people skip it and go straight to Motion, but I think it deserves a look before you add a dependency. It's native, it's performant, and it handles more than people expect.
WAAPI is still JavaScript though, which means it lives in the same world as your app logic. For simple UI transitions, CSS is still the right call. But for playback control, sequencing, or interruptible animations, reach for this first.
Hardware Acceleration
The browser doesn't do all its rendering in the same place. It splits work between the CPU and the GPU.
Everything JavaScript touches lives on the main thread. Layout, painting, event handlers, React renders. It all runs in the same queue, one thing at a time.
The GPU handles compositing. Once the browser knows what to paint, it can hand certain things off to the GPU and run them independently, separate from whatever JavaScript is doing.
transform and opacity are the two properties that get this. Animate either of them and the browser can keep that animation running even when the main thread is busy.
When Motion started lagging under heavy computation I didn't know why. I had to read through a few blogs on browser rendering to understand what was actually happening. That's when the CPU/GPU split started making sense.
requestAnimationFrame
requestAnimationFrame lets you schedule a function to run right before the browser paints the next frame. You call it, pass a callback, and the browser calls it back at the right time, usually 60 times per second.
function tick(timestamp) {
element.style.transform = `translateX(${x}px)`;
x += 1;
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);It's the right tool when CSS can't do what you need. Physics, canvas, or anything that needs to respond to real-time input. But it runs on the main thread, so it competes with everything else.
CSS animation
requestAnimationFrame
Toggle "Block CPU" and watch what happens. Both balls look the same until the thread is occupied. The CSS ball keeps moving. The rAF ball stutters and freezes.
The CSS animation is running on the GPU. It doesn't know or care what JavaScript is doing. The rAF callback just never fires when the main thread is blocked, so the animation stops where it is.
This is why transform and opacity matter so much. They're the two properties the browser can move off the main thread. Anything else stays in the queue.
will-change: transform is a hint that tells the browser to promote an element to its own GPU layer before the animation starts. It prevents the small stutter you sometimes see at the beginning of a heavy animation.
I haven't needed it much in practice, but it's worth knowing it exists. Use it sparingly, each layer takes up GPU memory.
Animation Libraries
CSS and WAAPI get you far, but complex interactions still require a lot of manual work. Spring physics, gesture handling, layout transitions when items are added or removed. It's all doable, but you end up writing a lot of code to handle edge cases.
Animation libraries sit on top of all of this and give you a simpler API. They handle the low-level work so you don't have to.
Motion
Motion (formerly Framer Motion) is the library I reach for most. I've used it for everything from micro-interactions and button feel to full landing page animations.
Under the hood it uses requestAnimationFrame to drive its animations, but it abstracts all of that away behind a declarative API.
Instead of calling .animate() or writing rAF loops, you replace your div with motion.div and describe what state things should be in. Motion figures out how to get there.
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>The two features I use the most are AnimatePresence and layout. AnimatePresence lets elements animate out when they're removed from the DOM, something CSS simply can't do on its own.
layout is the one that genuinely surprised me. Add it to a motion.div and when the DOM shifts, Motion automatically calculates the position change and animates between them. No FLIP math, no manual calculations. It just works.
// drag with spring snap-back
<motion.div drag="x" dragConstraints={{ left: -100, right: 100 }} />
// animate when scrolled into view
<motion.div whileInView={{ opacity: 1 }} initial={{ opacity: 0 }} viewport={{ once: true }} />The demo above is the same swipe-to-dismiss behavior you've seen in native apps. Drag an item, it follows your finger, spring snaps back if you let go early. Release fast enough and it flies off. The remaining items slide up smoothly to fill the gap.
The entire interaction is motion.div with drag, layout, and AnimatePresence. No manual WAAPI, no FLIP calculations, no rAF loop.
Motion is the right call when you need spring physics, gesture-driven interactions, or layout animations. It does carry a bundle cost around 50KB gzip, so for simple hover effects and transitions, CSS is still the better choice.
GSAP, anime.js, and others
GSAP has been around since before CSS animations were widespread and its real strength is complex sequencing. Timelines let you choreograph large multi-step animations with precise control over ordering, overlap, and delays. It's the standard for marketing sites and scroll-driven storytelling.
For UI work inside React, Motion is usually a better fit. anime.js covers similar ground at a fraction of the size, around 17KB minified, useful when you need to orchestrate a few elements without the overhead.
Beyond these, there's Lottie for playing After Effects animations exported as JSON, Three.js and Spline for 3D scenes in WebGL, and PixiJS for high-performance 2D canvas. They all fill different niches depending on what you're building.
Animation on the web goes much deeper than what's here. SVG animation lets you animate paths and strokes in ways the DOM can't.
The View Transitions API is one I've tried and it genuinely surprised me. Navigating between pages felt like a native app transition, like the web had finally caught up to mobile.
The tooling is only getting better. But the tools were never the hard part.
Getting something to move is the easy part. What makes an animation good is how it tells a story and how it affects the experience. Too many make a site feel jittery. Too long and it feels slow. The same animation repeated becomes noise.
The best sites have motion that is purposeful and restrained. It improves the experience without ever drawing attention to itself.
If you found this useful or want to talk animations, find me on X.