Overview
CSS owns motion. JavaScript animation libraries are for cases CSS cannot express. The rules are mechanical: animate transform and opacity, respect motion preferences, and pick transition or @keyframes by whether the change has a clear start state. The umbrella sits at css.
Use transition for state changes, @keyframes for sequences
transition interpolates between two values when a property changes. @keyframes defines a sequence of values to step through on its own.
- Hover, focus, open, disabled, theme swap:
transition. - Loading spinners, looping marquees, multi-step reveals:
@keyframes.
.button {
transition:
background-color 150ms ease,
transform 100ms ease;
}
.button:hover {
background-color: var(--color-brand-hover);
transform: translateY(-1px);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner {
animation: spin 1s linear infinite;
}If you reach for @keyframes to animate a hover, the change has two end states and a transition is shorter.
Animate transform and opacity only
Modern browsers composite transform and opacity on the GPU without re-running layout or paint. Every other property animation forces layout or paint per frame and tanks INP. See page-speed.
- Move:
transform: translate()nottop/left. - Resize:
transform: scale()notwidth/height. - Rotate:
transform: rotate()not multiple properties. - Fade:
opacitynotvisibilityordisplay.
/* Slow: triggers layout on every frame. */
.toast {
transition: top 200ms;
}
/* Fast: compositor-only. */
.toast {
transition: transform 200ms;
}If you need to animate a layout property (grid-template-rows: 0fr to 1fr), test on a mid-tier phone before shipping; some are now compositor-friendly, many still are not.
Respect prefers-reduced-motion
Some users get nauseated by motion. The OS exposes that preference; the page must honor it.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}This is the WCAG 2.3.3 baseline. For motion that carries information (a progress bar, a reveal), provide a static alternative when reduced motion is requested rather than disabling the cue entirely. See accessibility.
Use will-change sparingly and remove it after
will-change promotes an element to its own compositor layer ahead of time. The hint is useful right before an animation starts; permanent will-change eats memory and can blur text.
/* Set just before the animation runs, remove on completion. */
.menu[data-state="opening"] {
will-change: transform, opacity;
}
.menu[data-state="open"] {
will-change: auto;
}Do not write will-change: transform on every animated class as a habit. If the animation already runs smoothly, the property is harming you. Profile first.
Drive animation values with custom properties
CSS custom properties make animations themeable and reusable. Pair with css-custom-properties.
:root {
--motion-fast: 150ms;
--motion-base: 250ms;
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dialog {
transition:
opacity var(--motion-base) var(--ease-out),
transform var(--motion-base) var(--ease-out);
}To animate the custom property itself, register it with @property so the browser knows how to interpolate.
@property --tilt {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.card {
transition: --tilt 300ms;
transform: rotate(var(--tilt));
}
.card:hover {
--tilt: 2deg;
}Ship scroll-driven animations natively
animation-timeline ties an animation to a scroll position instead of a clock. Use it for reveal effects, scroll progress bars, and parallax without JavaScript scroll listeners.
@keyframes reveal {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
}
}
.section {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}view() ties the animation to the element’s own intersection with the viewport. scroll() ties it to an ancestor’s scroll progress. Chrome, Edge, and Safari (since Safari 26, September 2025) ship this unflagged; Firefox still sits behind the layout.css.scroll-driven-animations.enabled flag in stable as of mid-2026. Provide a non-animated fallback for browsers that ignore the timeline.
Common pitfalls
- Animating
height: auto. The compute pass cannot interpolateauto. Usegrid-template-rows: 0frto1fr, or animate amax-heightto a known value, or measure with aResizeObserverand set a pixel value before transitioning. display: noneblocks animation. Adisplay: noneelement does not run transitions when shown. Use the@starting-stylerule plustransition-behavior: allow-discretefor entry animations, or toggle a state class instead.- Layout thrash from non-compositor properties. A page that animates
top,left, orwidthwill jank on mobile. Convert totransform. - Stuck
will-change. Permanentwill-change: transformon every interactive element eats GPU memory and can blur subpixel text. Remove after the animation ends. - Ignoring reduced motion. A site without a reduced-motion stylesheet fails WCAG 2.3.3; see accessibility.