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. Both ship in Chrome and Edge today; Safari and Firefox are in progress as of 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.