Every browser's compositor can animate transform and opacity on the GPU without recalculating layout or repainting the rest of the page. Animate almost anything else — width, top, margin, box-shadow — and you're forcing the main thread to recompute layout on every single frame. At 60fps that's a 16.6ms budget per frame; a layout-triggering animation can blow through it easily on a mid-range phone, and the result is the janky, stuttering motion users associate with "cheap-feeling" websites.
These solve different problems, and mixing them up is the most common source of over-engineered CSS. Use a transition when a property changes once, in response to a state change like :hover or :focus. Use a keyframe animation when you need multiple steps, looping, or motion that starts on its own without a trigger.
/* Transition — reacts to a state change */
.button {
transform: scale(1);
transition: transform 180ms ease-out;
}
.button:hover {
transform: scale(1.04);
}
/* Keyframe animation — runs independently, can loop */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status-dot {
animation: pulse 2s ease-in-out infinite;
}
Instead of animating margin-top to slide an element into place (triggers layout on every frame), animate transform: translateY(), which the compositor handles independently:
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: slide-up 500ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
The both fill mode keeps the element at its "from" state before the animation starts and its "to" state after it ends, so there's no flash of the final position before the animation plays.
ease-in-out is the default most people reach for, but it makes UI motion feel slow and mechanical. For interface animation — modals opening, cards sliding in — a slight overshoot curve like cubic-bezier(0.16, 1, 0.3, 1) (sometimes called "ease-out-expo") reads as snappier and more natural because it front-loads the motion and settles gently, which is closer to how physical objects actually decelerate.
| Curve | Feel | Best for |
|---|---|---|
linear | Mechanical, constant speed | Loading spinners, progress bars |
ease | Slight ease at both ends (CSS default) | Small, subtle micro-interactions |
cubic-bezier(0.16,1,0.3,1) | Fast start, soft landing | Modals, drawers, card entrances |
cubic-bezier(0.68,-0.55,0.27,1.55) | Overshoot/bounce | Playful UI, badges, notifications |
UIXDraft's 180+ template bundle ships with animation timing already dialed in across buttons, cards, and page transitions — copy the CSS and adjust the brand color.
Browse the templates →A common request is "make the list items fade in one after another" — this looks like it needs JS, but CSS custom properties combined with nth-child handle it cleanly:
.list-item {
opacity: 0;
animation: slide-up 400ms ease-out forwards;
animation-delay: calc(var(--i) * 60ms);
}
<li class="list-item" style="--i: 0">First</li>
<li class="list-item" style="--i: 1">Second</li>
<li class="list-item" style="--i: 2">Third</li>
The --i custom property is set inline per item (trivial to generate in any templating language), and calc() turns it into an incrementing delay. No animation library needed for what's really just arithmetic.
Chrome DevTools' Performance panel is more useful here than guessing. Record a few seconds of the animation running, then check the summary for a high "Rendering" or "Painting" percentage — that's the signal an animated property is triggering layout or paint instead of running on the compositor. The Layers panel (under More Tools) will also show whether an element actually got its own GPU layer, confirming whether will-change or transform did what you expected.
will-change hints to the browser that a property is about to change, letting it promote the element to its own GPU layer ahead of time. Applying it everywhere backfires — each promoted layer consumes GPU memory, and too many can make scrolling worse, not better. Apply it right before the animation starts, and remove it after:
.modal {
will-change: transform, opacity;
}
/* Remove via JS after the animation ends, or scope it to :hover
so the browser doesn't hold the layer indefinitely */
CSS can't transition to or from height: auto directly — the browser doesn't know the target pixel value to interpolate toward, so it either jumps instantly or does nothing. For years the workaround was measuring the element's height in JavaScript. The grid-template-rows trick avoids that entirely:
.accordion-body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms ease;
overflow: hidden;
}
.accordion-body.open {
grid-template-rows: 1fr;
}
.accordion-body > div {
min-height: 0; /* required for the grid track to actually collapse */
}
Animating a fraction unit (0fr → 1fr) is something height alone can't do, and it works with content of any length — no measuring, no JavaScript, no layout thrashing.
Tying an animation to scroll position used to mean a scroll event listener, a requestAnimationFrame loop, and manual math — all running on the main thread, competing with everything else. The animation-timeline property replaces that with a native CSS timeline driven by scroll position, evaluated off the main thread:
.progress-bar {
animation: grow linear;
animation-timeline: scroll(root);
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Support landed in Chrome and Edge first, with Firefox and Safari catching up — treat it as progressive enhancement for now and keep a static fallback state so the element still looks correct in browsers that ignore animation-timeline.
Vestibular disorders can make large-scale motion genuinely uncomfortable or nauseating, and this isn't an edge case — it's a documented accessibility requirement. Wrap non-essential animation in a media query so users who've set the OS-level reduced motion preference get a calmer experience:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Mobile GPUs have far less headroom than desktop ones, so animations that touch layout-triggering properties (width, top, box-shadow) show jank on phones before they show it on a laptop. Switch to transform and opacity and the difference usually disappears.
CSS handles the vast majority of UI motion — hovers, entrances, loading states — with less code and no JS execution cost. Reach for the Web Animations API or a JS library only when you need to sequence animations dynamically, pause/reverse them based on scroll position, or coordinate timing across many elements.
Not efficiently — both trigger paint on every frame. For a glowing shadow effect, animate the opacity of a pseudo-element that already has the shadow applied, rather than animating the shadow's blur or spread values directly. The same trick works for gradients: layer a second gradient background on a pseudo-element and cross-fade its opacity instead of interpolating the gradient stops. It's a small workaround, but it's the difference between an effect that stays smooth under load and one that visibly drops frames the moment several instances animate on screen at once.