CSS Animation Patterns
Quick Guide: Transitions carry state changes (hover, focus, a toggled attribute);
@keyframescarries motion that loops, auto-plays, or has more than two steps;animation-timelinecarries scroll- and viewport-linked progress. Confining animation totransformandopacitykeeps the frames on the compositor thread, and every animation gets aprefers-reduced-motionbranch.
Detailed Resources:
- examples/core.md — token system, interactive states, entrance, spinner, toast, reduced-motion
- examples/transitions.md — multi-property transitions, staggered delays, accordions, colour, links
- examples/keyframes.md — scroll-driven timelines,
@propertygradients, typewriter, stagger, clip-path morphs - reference.md — easing catalogue, property cost table, duration guidance, browser support
Which path applies
- The motion is a state change —
:hover,:focus-visible, a data attribute, a toggled class — then atransitionon the base rule is the whole mechanism; follow examples/transitions.md. - The motion loops, auto-plays on mount, or passes through more than two states — then it needs
@keyframesand ananimationshorthand; follow examples/keyframes.md. - The motion tracks scroll position or viewport entry — then the driver is
animation-timeline: scroll()orview()rather than time, and the keyframes describe progress from 0 to 1; follow examples/keyframes.md.
Before writing CSS animation code
Animate transform and opacity. Both are composited, so the frames run off the main thread and
survive a busy tab; width, top and margin re-run layout on every frame instead.
Give every animation a prefers-reduced-motion branch. The preference is a vestibular safety
setting rather than an off switch — an opacity fade at a shorter duration usually satisfies it while
keeping the state change legible.
Use ease-out on enter and ease-in on exit. An element arriving decelerates into place and one
leaving accelerates away; linear reads as mechanical for anything but continuous rotation.
Scope will-change to the interaction that needs it. Each declaration holds a compositing layer
for as long as the rule applies, so a blanket selector holds one per element on the page at once.
Auto-detection: @keyframes, transition-property, transition-duration, animation-timeline, scroll-timeline, view-timeline, animation-range, animation-fill-mode, prefers-reduced-motion, will-change, cubic-bezier, linear(), @property, steps(), transform-origin
Applies to:
- State-change motion driven by a pseudo-class, a data attribute or a toggled class
- Autonomous motion — spinners, pulses, shimmer, attention cues
- Scroll-linked and viewport-entry progress
- Entrance and exit motion whose trigger is a class or attribute the page already sets
Handled elsewhere:
- Playback control at runtime — pause, reverse, seek, or read progress. A CSS declaration exposes no
handle; the Web Animations API is where one comes from, either
element.animate()orelement.getAnimations()over what CSS already declared - Motion whose velocity carries across an interruption, such as a spring picked up mid-gesture
- Pointer-tracking drag, where the animated value is the pointer position itself
- Compositing an outgoing and an incoming view together across a navigation or view swap
Easing selection
Element entering -> ease-out (fast start, slow settle)
Element exiting -> ease-in (slow start, fast departure)
Symmetric motion -> ease-in-out
Continuous rotation -> linear
Playful, overshooting -> cubic-bezier with a control point past 1
Anything else -> ease-out
ease, the browser default, is generic enough that two adjacent animations using it read as
unrelated; name the curve instead.
What CSS expresses
- Scroll and viewport progress —
animation-timeline: scroll()orview(), withanimation-rangedeciding where progress starts and ends - Sequencing across elements —
animation-delaycomputed from an--indexcustom property, withbackwardsfill so the pre-animation state holds during the delay - Values computed at runtime — write them into a custom property; the animation itself stays declarative and reads the property each frame
- Overshoot and arbitrary curves — a
cubic-bezierpast the 0–1 range, orlinear()with a point list for a curve no cubic can express
Core patterns
Pattern 1: Animation Token System
Durations, easings and travel distances defined once as custom properties, so motion stays consistent across components and is retunable in one place.
:root {
--duration-fast: 150ms;
--duration-normal: 250ms;
--ease-out: cubic-bezier(0, 0, 0.2, 1); /* enter */
--ease-in: cubic-bezier(0.4, 0, 1, 1); /* exit */
--ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); /* overshoot */
--lift-md: -4px;
}
Full code: examples/core.md
Pattern 2: Compositor-Only Transitions
Name each property being transitioned, and express movement and size as transform so no frame
triggers layout.
.card {
transition:
transform var(--duration-fast) var(--ease-out),
opacity var(--duration-fast) var(--ease-out);
}
.card:hover {
transform: translateY(var(--lift-md)) scale(1.02);
}
translate() replaces top/left, scale() replaces width/height, and a pseudo-element whose
opacity animates replaces an animated box-shadow.
Full code: examples/core.md
Pattern 3: Prefers-Reduced-Motion
Two shapes. Progressive enhancement makes the still state the base and opts motion in, so an animation added later cannot escape the check:
.element {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: no-preference) {
.element {
animation: fade-slide-in var(--duration-normal) var(--ease-out);
}
}
Graceful degradation animates by default and overrides under reduce — the right shape when the
reduced form is a shorter fade rather than nothing:
@media (prefers-reduced-motion: reduce) {
.notification {
animation: fade-in calc(var(--notification-duration) * 0.5) var(--ease-out);
}
}
Reduced motion does not mean no animation. Opacity is generally safe; what it replaces is spatial travel, scale and rotation.
Full code: examples/core.md
Pattern 4: @keyframes
For motion that loops, auto-plays on mount, or passes through more than two states.
@keyframes fade-slide-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal {
animation: fade-slide-in var(--modal-enter-duration) var(--ease-out) forwards;
}
forwards holds the final state after the run; backwards shows the initial state during
animation-delay.
Full code: examples/core.md and examples/keyframes.md
Pattern 5: Will-Change Scoping
will-change promotes the element to its own compositing layer, which costs GPU memory
proportional to the element's painted area. Declare it on the rule that is about to animate.
.card:hover {
will-change: transform;
}
Full code: examples/core.md
Pattern 6: Scroll-Driven Animations
animation-timeline drives keyframes from scroll progress instead of elapsed time, with no
scroll listener and no per-frame JavaScript.
.progress-bar {
animation: grow-width linear;
animation-timeline: scroll();
}
@keyframes grow-width {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
scroll() tracks a scroll container's position; view() tracks the element's own passage through
the viewport, with animation-range bounding it.
Browser support: Chrome/Edge 115+, Safari 26+, Firefox behind a flag.
Full code: examples/keyframes.md
Pattern 7: @property for Custom Property Animation
Registering a custom property gives it a type, which is what makes it interpolable — gradient angles and colour stops animate only once registered.
@property --gradient-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
@keyframes rotate-gradient {
to {
--gradient-angle: 360deg;
}
}
Browser support: Chrome/Edge 85+, Safari 16.4+, Firefox 128+.
Full code: examples/keyframes.md
Red flags
Breaks at runtime:
transition: all— picks up every property a later edit adds, including layout-triggering ones — name each transitioned property explicitly- Animating
width,height,top,left,marginorpadding— re-runs layout every frame and drops frames as soon as the main thread is busy — animatetransformand leave layout still - Animating
box-shadow— repaints the element and its shadow each frame — animate theopacityof a pseudo-element that carries the shadow will-changeon a permanent or broad selector — holds one compositing layer per matched element, and on mobile enough layers exhaust GPU memory and kill the tab — declare it on the interaction rule only- An animation with no
prefers-reduced-motionbranch — full-travel motion reaches users who have asked their OS for none — add the branch when the animation is written, not afterwards - An enter animation without
forwards— the element snaps back to its pre-animation state on the final frame — add the fill mode
Surprising behaviour:
transformon an ancestor creates a containing block, so aposition: fixeddescendant anchors to that ancestor rather than to the viewportwill-changecreates a stacking context, changing howz-indexresolves against siblings- Without
animation-fill-mode: backwards, a delayed animation renders its final state during the delay instead of its first frame display: nonecannot be animated — useopacitywithvisibility, orgrid-template-rowsanimating0frto1fr- SVG path drawing animates
stroke-dasharrayandstroke-dashoffset;transformmoves the path rather than drawing it - A
scroll()timeline needs a scrollable ancestor — anoverflow: hiddenparent yields no progress - Animations do not run in print, so the pre-animation state has to be legible on paper
- Durations past roughly 1s read as sluggish rather than deliberate