CSS width Animation — Why It Breaks 16ms Budgets on Mobile
Animating width forces layout recalc every frame, dropping below 60fps on budget Android.
- CSS transitions animate between two states triggered by property changes.
- CSS @keyframes define multi-step timelines with unlimited waypoints.
- Only transform and opacity trigger compositor-only rendering — all else causes layout/paint.
- Staggering with CSS variables + calc() creates cascading entrances without JavaScript.
- animation-fill-mode: both is the default for entrance animations to avoid flash.
- Always include @media (prefers-reduced-motion: reduce) for accessibility compliance.
Imagine a light switch that teleports from OFF to ON — that's a style change with no transition. Now imagine a dimmer switch that smoothly fades the light up over two seconds — that's a CSS transition. A CSS animation is like a choreographed dance routine: you define exactly what the dancer does at each beat (0%, 50%, 100%), and the browser performs it on cue, even without anyone touching the switch.
Every polished web product you've ever used — Stripe's pricing page, Linear's task cards, Apple's product reveals — has one thing in common: motion that feels intentional. That motion isn't magic. It's CSS transitions and animations doing the heavy lifting without a single line of JavaScript. When an element moves, fades, or bounces in a way that feels natural, your brain registers the UI as trustworthy and responsive. Skip the motion and everything feels like a 1997 government website.
The problem most developers run into isn't knowing the syntax — it's knowing WHY the browser behaves the way it does, WHEN to reach for a transition versus a full animation, and HOW to avoid jank that tanks your Lighthouse scores. Animating the wrong CSS property (looking at you, margin) can silently murder your 60fps budget. Animating transform and opacity instead keeps things silky because those two properties skip the expensive layout and paint steps entirely.
By the end of this article you'll know exactly how the browser renders motion, when to use transition versus @keyframes, how to sequence multi-step animations, and the exact gotchas that trip up even experienced developers — so you can ship smooth, performant UI with confidence.
CSS Transitions — Smooth State Changes Without a Single Keyframe
A CSS transition is the simplest form of animation: you tell the browser 'when THIS property changes, don't snap — glide over X seconds.' That's it. No keyframes, no JavaScript, no complexity.
The four pillars of transition are: the property you're watching, the duration of the glide, the timing function (the pace curve), and the delay before it starts. You almost always set these on the element's default state — not the hover or active state — so the transition runs in both directions (in AND out).
The timing function is where most developers stop reading, but it's where all the personality lives. ease starts fast and slows down (great for elements entering the screen). ease-in-out ramps up then back down (great for elements moving across the screen). linear is robotic and usually wrong for UI — it's only really useful for infinite loaders. cubic-bezier() lets you craft a custom curve for that satisfying 'spring' feeling.
The golden rule: only transition transform and opacity for anything that must hit 60fps. Transitioning width, height, margin, or top forces the browser to recalculate layout on every frame — that's expensive and causes jank on lower-end devices.
CSS Keyframe Animations — When You Need a Full Choreography
Transitions only go from A to B. But what if you need A → B → C → back to A, looping forever? That's where @keyframes come in. A keyframe animation defines a named timeline with as many waypoints as you need, then you attach that timeline to any element via animation-name.
Think of @keyframes as writing a script for a play. transition is an actor who improvises between two lines. @keyframes is a full stage direction that says: at the opening curtain do THIS, at the halfway point do THAT, and at the curtain call do THIS OTHER THING.
The animation shorthand packs in eight sub-properties: name, duration, timing-function, delay, iteration-count, direction, fill-mode, and play-state. The ones that trip people up are fill-mode and direction.
fill-mode: forwards means 'hold the final keyframe state after the animation ends.' Without it, your element snaps back to its original style the moment the animation finishes — which looks like a bug but is actually the spec-correct default. animation-direction: alternate makes the animation play forward then backward on odd/even iterations, which is perfect for pulse or breathe effects without needing to mirror your keyframes manually.
animation-fill-mode: forwards, the element snaps back to opacity: 0 the instant the animation ends. The fix is always animation-fill-mode: forwards on one-shot animations, or structure your default styles so the element is already at its 'final' state and use animation-direction: reverse to animate in.Sequencing and Staggering — Making Multiple Elements Dance Together
A single animated element is fine. A whole list of cards that cascade in one by one feels premium. The technique is called staggering: each element gets the same animation, but with a progressively increasing animation-delay.
The cleanest way to stagger in plain CSS is with the --stagger-index CSS custom property (CSS variable) set inline per element. Your JavaScript or your templating engine sets the index as an inline style attribute. The CSS then reads it and calculates the delay with calc(). This keeps your animation logic in CSS where it belongs, while letting your data layer drive the count.
This pattern is used everywhere: Vercel's dashboard on load, GitHub's contribution graph, Notion's sidebar items. Once you see it, you can't unsee it. The key insight is that the delays are relative to when the animation starts, so if the container has a 200ms entrance delay, all children should account for that in their own delays.
When sequencing is more complex — a loading screen with a logo, then a tagline, then a call-to-action button appearing in order — use explicit delay values rather than a formula. Explicit is clearer and easier to tune during design review.
--stagger-index is set by the server or JS loop, calc() computes the delay in CSS, and zero JavaScript is required to drive the timing. That separation of concerns is the senior-developer answer.calc() and CSS custom properties.Performance: The Compositor Thread and Why `transform` is King
The browser renders a page in four main stages: Style (calculates styles), Layout (calculates geometry), Paint (fills pixels), and Composite (blits layers together). When you animate a property that triggers Layout or Paint, the browser must recalculate those stages on every frame — that's expensive and causes jank.
Properties like transform and opacity only trigger the Composite stage. They run on the compositor thread, which is separate from the main thread. The main thread handles JavaScript, style calculation, layout, and paint. The compositor thread runs on the GPU and can handle transform and opacity changes at 60fps without blocking the main thread. That's why you should always replace width with transform: scaleX(), top/left with transform: , and translate()margin with transform or padding changes.
will-change is a CSS property that tells the browser to prepare for changes to a property. Using will-change: transform before an animation starts can prevent jank by promoting the element to its own compositor layer. But don't overuse it — each promoted layer consumes GPU memory. Apply it right before the animation starts and remove it after (or use JavaScript to add/remove the class).
will-change consumes GPU memory. Don't apply it to dozens of elements at once — you'll exhaust the layer limit and cause battery drain. Apply it only to elements that will animate soon, and remove it after the animation completes. A safer default is transform: translateZ(0) which also promotes without memory cost, but avoid using it on elements that are never animated.Real-World Patterns: Hover Effects, Loaders, and Page Transitions
Now let's combine what you've learned into three production-ready patterns.
Pattern 1: Micro-interaction hover effect — a card that lifts on hover, with a subtle shadow and border color shift. Use transition on transform, box-shadow, border-color. Duration 150ms ease-out for snappy feedback.
Pattern 2: Infinite loading spinner — a @keyframes animation that rotates continuously. Use animation: spin 1s linear infinite; to avoid the eye-strain of easing curves. For accessibility, reduce motion users should see a static spinner (or hide it).
Pattern 3: Page transition — when navigating between views in a SPA, fade out current content and fade in new content. Use @keyframes on a container with animation-fill-mode: both. Coordinate delays with JavaScript to match route change timing.
Key to all patterns: test on real devices, especially low-end Android. A hover animation that's 60fps on an iPhone 15 might be 15fps on a Moto G. Always throttle CPU in DevTools.
- Feedback: hover, click — 80-200ms, subtle (transform/y-axis change)
- Attention: pulsing dots, loading spinners — infinite, subtle opacity/scale
- Spatial orientation: page transitions, modals — 300-600ms, combine opacity + translate
- Always reduce motion for people with vestibular disorders — it's not optional
Dashboard Progress Bar Causes Mobile Jank — The width Animation Trap
width triggers layout in every frame: the browser must recalculate the positions and sizes of all sibling elements because width changes affect the document flow. On devices with limited CPU (common on Android under $300), this layout work exceeds the 16ms budget per frame.width animation with transform: scaleX() on a nested inner element, using transform-origin: left to grow from left to right. The outer container remained at full width. This moved the animation to the compositor thread, eliminating layout recalculations.- Never animate layout-triggering properties (width, height, margin, top/left) in performance-critical UI sections.
- Use
transformandopacityas replacements — they skip layout and paint and run on the GPU compositor thread. - Always test animations on low-end devices or use CPU throttling in DevTools (Chrome: Performance tab -> CPU: 6x slowdown).
- Set a performance budget for animation: if you exceed 10ms of layout/paint per frame, find a compositor-only alternative.
will-change: transform to promote it.position: absolute or position: fixed to remove it from layout. Also verify you're not animating width/height of a non-positioned element.will-change: transform to the element. If still janky, reduce the number of concurrently animating elements.Key takeaways
transition on the base state, never on :hovertransform and opacity for performance-critical motion is the correct default for entrance animations — backwards hides the element during the delay, forwards` holds the final frame after completion.will-change sparingly and only during animationsCommon mistakes to avoid
4 patternsPutting `transition` on the hover state instead of the base state
transition on the base element selector (.button), not on .button:hover. The hover state should only declare the destination values.Animating `width`, `height`, `top`, `left`, or `margin`
transform equivalents. Use transform: scaleX() instead of animating width. Use transform: translate() instead of animating top/left. These two properties are the only ones guaranteed to stay on the GPU compositor thread.Forgetting `animation-fill-mode: both` on entrance animations
fill-mode: both applies the from keyframe values during the delay (keeping the element hidden/offset) and holds the to keyframe after completion (keeping the element visible). Make this your default for any entrance animation.Not including a `prefers-reduced-motion` override
@media (prefers-reduced-motion: reduce) block that either disables the animation (animation: none) or replaces it with a static visual. This is a WCAG requirement (Success Criterion 2.3.3).Interview Questions on This Topic
What's the difference between CSS transitions and CSS animations, and how do you decide which one to reach for?
Frequently Asked Questions
That's HTML & CSS. Mark it forged?
5 min read · try the examples if you haven't