Keyframe Animation Patterns: Spec-Compliant Architectures for Modern UIs
Master production-ready keyframe animation patterns to build performant, maintainable interfaces. This guide bridges foundational concepts from CSS-Only Micro-Interactions & Animations with scalable component architectures, focusing on declarative state mapping, GPU-optimized transforms, and spec-compliant motion design.
Key Implementation Principles:
- Declarative state routing without JavaScript
- Compositor-only property optimization
- Modular
@keyframesarchitecture for design systems - Accessibility-first motion constraints
Prerequisites
Before working through this guide you should already be comfortable with:
- CSS transition fundamentals — in particular why a transition needs two known endpoints, which is precisely the constraint
@keyframesremoves. - Custom properties and
calc(), since almost every scalable keyframe pattern parameterises timing rather than duplicating rules. - The rendering pipeline — style, layout, paint, composite — and which properties touch which stage.
- State selectors without JavaScript:
:checked,:target,:focus-within,:has(), and attribute selectors. prefers-reduced-motionas a media feature, covered in depth in the accessibility in CSS animations guide.
Core concept: keyframes are sampled, not executed
A @keyframes rule is not a script. It is a named list of property-value snapshots indexed by progress, and the browser samples it. Understanding what happens at sampling time explains most of the surprising behaviour in this area.
At every frame, the browser computes the animation's iteration progress — a number from 0 to 1 derived from elapsed time, animation-duration, animation-direction, and animation-iteration-count. It then resolves, for each animated property independently, the pair of keyframes that bracket that progress and interpolates between them. Four rules follow from that model.
Properties are resolved independently. If transform appears at 0%, 40%, and 100% while opacity appears only at 0% and 100%, the two properties use completely different bracketing pairs at the same instant. They are not synchronised to the same keyframe boundaries, and there is no requirement that every keyframe mentions every property.
Missing endpoint keyframes are synthesised. If a property appears in the keyframe list but not at 0% or 100%, the browser constructs an implicit keyframe at that edge using the element's underlying computed value — the value it would have had with no animation running. This is why an animation can start from wherever the element already is without you writing the starting value at all.
animation-timing-function applies per keyframe segment, not to the whole animation. A timing function declared inside a keyframe block governs the interval starting at that keyframe. This is a genuinely useful lever: you can decelerate into a mid-point and then accelerate out of it, which no single curve on the shorthand can express.
Duplicate names replace, they do not merge. If two @keyframes rules share a name, the last one in document order (in the winning cascade origin) is used in its entirety and the earlier one is discarded. Ordinary declarations cascade property by property; keyframe rules do not. Reusing a generic name like fade-in in a second stylesheet silently deletes the first animation, and nothing in DevTools flags it.
Alongside sampling, an animation has a lifecycle: it is either idle, pending, running, paused, or finished. animation-play-state moves it between running and paused without losing progress, and animation-fill-mode decides whether the animated values apply outside the active interval — backwards during the delay, forwards after the end, both for both. The default is none, which is why an element snaps back the instant its animation finishes.
Spec Reference: CSS Animations Level 1 defines keyframe resolution, the fill modes, and the cascade rule for duplicate names.
Syntax and parameters
| Token | Accepted values | Initial value |
|---|---|---|
animation-name | none, or a <custom-ident> matching a @keyframes rule | none |
animation-duration | <time>, non-negative | 0s |
animation-timing-function | linear, ease, ease-in, ease-out, ease-in-out, steps(), cubic-bezier(), linear() | ease |
animation-delay | <time>, negative allowed (starts mid-animation) | 0s |
animation-iteration-count | <number> (fractional allowed), infinite | 1 |
animation-direction | normal, reverse, alternate, alternate-reverse | normal |
animation-fill-mode | none, forwards, backwards, both | none |
animation-play-state | running, paused | running |
animation-timeline | auto, none, scroll(), view(), <dashed-ident> | auto |
| Keyframe selector | from (= 0%), to (= 100%), <percentage>, comma-separated | — |
Every longhand accepts a comma-separated list, and the lists are matched by index against animation-name and cycled if short. animation: fade 1s, slide 2s therefore declares two independent animations on one element, each with its own clock.
Two values in that table are underused. A fractional animation-iteration-count such as 0.5 plays exactly half the timeline and stops — useful for running only the first half of an alternate pair. And a negative animation-delay starts the animation already in progress, which is the mechanism behind most seamless looping backgrounds: give each of several layers a different negative delay on the same infinite animation and they will be permanently out of phase without any extra keyframes.
State-Driven Keyframe Architecture
Modern UI motion should be driven by state, not imperative JS calls. By leveraging CSS custom properties as animation controllers and modern selectors like :has() and :target, you can decouple layout logic from visual feedback layers.
Implementation: CSS Variable Routing
Use custom properties to toggle animation states, allowing you to swap entire sequences without rewriting selectors. Combine this with :has() for parent-level state observation.
<!-- HTML -->
<div class="card" data-state="idle">
<div class="card__content">
<h3>Declarative Motion</h3>
<p>State-driven architecture reduces JS overhead.</p>
</div>
<div class="card__indicator"></div>
</div>
/* CSS */
:root {
--anim-duration: 0.4s;
--anim-easing: cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card {
--state: idle;
position: relative;
overflow: hidden;
border-radius: 12px;
background: #f8fafc;
}
/* State routing via custom property */
.card[data-state="active"] {
--state: active;
}
.card__indicator {
position: absolute;
inset: 0;
background: rgba(59, 130, 246, 0.1);
opacity: 0;
transform: scale(0.95);
animation: var(--state, idle) var(--anim-duration) var(--anim-easing) forwards;
}
@keyframes active {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
/* Fallback for idle state */
@keyframes idle {
0%,
100% {
opacity: 0;
transform: scale(1);
}
}
/* Parent-driven activation using :has() */
.card:has(.card__content:hover) {
--state: active;
}
The important structural point is that animation-name is an ordinary property and therefore fully cascadable. Any selector that can change a custom property can change which timeline plays — a media query, a @container rule, an attribute, or an ancestor's :has(). That indirection is what makes the pattern pages later in this section compose rather than conflict.
Spec Reference: CSS Custom Properties Level 1, CSS Selectors Level 4 (:has())
Step-by-step implementation
The following sequence builds one component — a self-dismissing status message — from a single keyframe to a production timeline. A toast is a good teaching case because it needs an entrance, a hold, and an exit, which is exactly the shape a transition cannot express.
Step 1 — One animation, no lifecycle handling
.toast {
padding: 0.85rem 1.1rem;
border-radius: 10px;
background: #1e293b;
color: #f8fafc;
animation: toast-in 0.3s ease-out;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
This works, but only accidentally: the to keyframe happens to match the element's resting style, so animation-fill-mode: none causes no visible snap. Change the resting style and it breaks.
Step 2 — Make the resting state explicit with a fill mode
.toast {
padding: 0.85rem 1.1rem;
border-radius: 10px;
background: #1e293b;
color: #f8fafc;
animation: toast-in 0.3s ease-out both;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
both is backwards plus forwards: the from values apply during any delay, and the to values persist afterwards. For entrance animations this is almost always the right choice, because it removes the dependence on the resting style matching by luck.
Step 3 — Fold the hold and the exit into one timeline
Rather than chaining two animations and coordinating their delays, express the whole life of the toast as percentages of a single duration. With a four-second total, an entrance occupying 8% is 320ms and an exit occupying the last 10% is 400ms.
.toast {
padding: 0.85rem 1.1rem;
border-radius: 10px;
background: #1e293b;
color: #f8fafc;
animation: toast-life 4s both;
}
@keyframes toast-life {
0% {
opacity: 0;
transform: translateY(12px);
}
8% {
opacity: 1;
transform: translateY(0);
}
90% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-8px);
}
}
Note that the 8% to 90% stretch declares identical values. That is not redundancy — it is how you express "hold" in a sampled model. Without the 90% keyframe the browser would interpolate slowly from 8% all the way to 100%, and the toast would drift upward for three and a half seconds.
Step 4 — Give each segment its own curve
A single timing function on the shorthand applies to every segment, so the exit would inherit the entrance's easing. Declare curves per keyframe instead.
.toast {
animation: toast-life 4s both;
}
@keyframes toast-life {
0% {
opacity: 0;
transform: translateY(12px);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
8% {
opacity: 1;
transform: translateY(0);
animation-timing-function: linear;
}
90% {
opacity: 1;
transform: translateY(0);
animation-timing-function: ease-in;
}
100% {
opacity: 0;
transform: translateY(-8px);
}
}
Each function governs the segment that begins at its keyframe: decelerate in, hold flat, accelerate out.
Step 5 — Parameterise the timing
Percentages are locked to the duration, so a toast that needs to stay longer would need a whole second keyframe rule. Pull the duration out to a custom property and let the call site change it.
.toast {
--toast-life: 4s;
animation: toast-life var(--toast-life) both;
}
.toast--persistent {
--toast-life: 9s;
}
Because the shape is expressed in percentages, lengthening the life stretches the hold far more than the entrance in absolute terms — usually what you want. When you need the entrance to stay fixed regardless of total length, that is the point to split into two animations with an animation-delay.
Step 6 — Gate the motion
.toast {
--toast-life: 4s;
animation: toast-life var(--toast-life) both;
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation: toast-fade var(--toast-life) both;
}
@keyframes toast-fade {
0% { opacity: 0; }
8%, 90% { opacity: 1; }
100% { opacity: 0; }
}
}
The reduced-motion variant keeps the timing and the self-dismissal — both are functional, not decorative — and removes only the spatial displacement.
Annotated production example: a status message region
This assembles the steps above into a component you can paste directly, with the accessibility scaffolding a real toast needs.
<div class="toast-region" role="status" aria-live="polite">
<p class="toast">Draft saved.</p>
<p class="toast toast--persistent">Upload failed — retrying.</p>
</div>
.toast-region {
--toast-life: 4.5s;
--toast-ease-in: cubic-bezier(0.2, 0.8, 0.2, 1);
position: fixed;
inset-block-end: 1.5rem;
inset-inline-end: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
/* Establishes a stacking and containment boundary so the toasts'
compositing never invalidates the rest of the page. */
contain: layout paint;
}
.toast {
max-inline-size: 22rem;
margin: 0;
padding: 0.85rem 1.1rem;
border-radius: 10px;
background: #1e293b;
color: #f8fafc;
/* `both` means the 0% values apply before the animation starts and the
100% values persist afterwards, so the element never snaps. */
animation: toast-life var(--toast-life) both;
}
.toast--persistent {
--toast-life: 9s;
}
/* Hovering or focusing anything in the region freezes every timeline in
place. This is the WCAG 2.2.2 escape hatch, implemented in pure CSS:
animation-play-state pauses without losing progress. */
.toast-region:hover .toast,
.toast-region:focus-within .toast {
animation-play-state: paused;
}
@keyframes toast-life {
0% {
opacity: 0;
/* translateY only — no width/height, so this stays on the compositor. */
transform: translateY(12px);
animation-timing-function: var(--toast-ease-in);
}
8% {
opacity: 1;
transform: translateY(0);
animation-timing-function: linear;
}
/* The flat 8%–90% stretch IS the hold. Removing it would make the
toast drift for the whole duration instead of resting. */
90% {
opacity: 1;
transform: translateY(0);
animation-timing-function: ease-in;
}
100% {
opacity: 0;
transform: translateY(-8px);
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation-name: toast-fade;
}
}
@keyframes toast-fade {
0% { opacity: 0; }
8%, 90% { opacity: 1; }
100% { opacity: 0; }
}
Three points make this production-grade rather than demo-grade. role="status" with aria-live="polite" means the text is announced without the animation being involved at all — motion is never the only channel. animation-play-state: paused on :hover and :focus-within satisfies the requirement that auto-dismissing content be pausable, using nothing but CSS. And contain: layout paint on the region keeps the fixed-position stack from participating in the document's layout work each frame.
Where each pattern in this section fits
The pages below this guide are not variations on one idea; each solves a structurally different problem. Use this to decide which one you actually need.
| The trigger is | The pattern | Page |
|---|---|---|
A form control's own :checked state | Animate a pseudo-element positioned against the native input | Toggle switches and checkboxes |
| Nothing — it runs while waiting | An infinite timeline on a placeholder shape or gradient | Loading spinners and skeletons |
| One event, but many elements must respond in sequence | One keyframe reused with a per-item animation-delay offset | Staggered list animations |
| The component's own available width | Reassign animation-name inside a @container rule | Container-query-triggered animations |
| A user opening a section, with unknown content height | Interpolate an intrinsic size on a native disclosure element | Accordions and disclosure widgets |
Notice that four of the five change which animation runs or when it starts, rather than what is inside the keyframe block. That is the central architectural lesson of this section: keep the keyframe lists few and generic, and put the variation in animation-name, animation-delay, and animation-play-state.
Performance & GPU Acceleration Strategies
Achieving consistent 60fps requires strict adherence to compositor-only properties. Animating width, height, top, left, or margin forces synchronous layout recalculations (reflow), which blocks the main thread. Restrict motion to transform, opacity, and filter to leverage the browser's compositor thread.
Keyframes carry a cost transitions do not: an infinite animation runs forever, whether or not the element is on screen. A page with a dozen looping decorative animations keeps the compositor busy and the device awake even when nothing is visible. Two mitigations are worth applying by default — contain: paint on the animating element's container so the invalidated region stays small, and content-visibility: auto on off-screen sections so their animations are skipped entirely.
Implementation: Hardware-Accelerated Morphing
Use transform: translate3d() to force GPU layer promotion, and apply will-change strategically.
/* CSS */
.morph-container {
/* Force GPU layer creation */
transform: translateZ(0);
will-change: transform, opacity;
}
.morph-target {
animation: hardware-morph 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes hardware-morph {
0% {
transform: scale(0.8) translate3d(0, -10px, 0);
opacity: 0;
}
100% {
transform: scale(1) translate3d(0, 0, 0);
opacity: 1;
}
}
/* Cleanup will-change post-animation to free memory */
.morph-target.animation-complete {
will-change: auto;
}
Accessibility notes specific to keyframes
Because keyframe animations can run without user input and repeat indefinitely, they trigger accessibility requirements that transitions rarely do:
- Anything that moves, blinks, or scrolls automatically for more than five seconds must be pausable, stoppable, or hideable.
animation-play-state: pausedunder:hoverand:focus-withincovers this without JavaScript, as in the toast above. - Flashing must stay at or below three flashes per second. An
alternateanimation flashes twice per iteration, so aninfinite alternateat0.3sis already at the limit. - Large-area movement is a vestibular trigger even at a comfortable speed. Cap displacement and prefer opacity cross-fades for anything that covers a substantial part of the viewport; the vestibular-safe animation patterns page sets concrete thresholds.
- Never convey information by motion alone. If a pulsing border is the only indication that a field is invalid, that state does not exist for a user with motion suppressed.
DevTools Debugging Workflow
- Confirm the name resolved. Select the element and read
animation-namein the Computed pane. A value ofnone, or a name with no matching@keyframes, produces zero errors and zero motion — this is the single most common cause of "my animation does nothing". - Inspect the keyframe list. In Chrome and Edge, the Styles pane renders the matched
@keyframesrule directly beneath the element's rules, with an editable preview per keyframe. Firefox shows the same in the Rules view. If the rule you expect is not listed, a later rule with the same name replaced it. - Scrub the timeline. Open the Animations panel from the DevTools command menu, trigger the animation, and drop playback speed to 25% or 10%. Each animation appears as a scrubable bar; drag the playhead to find the exact percentage where the motion goes wrong.
- Check the composite path. In the Rendering panel enable Paint flashing and Layer borders. Continuous green flashing during a loop means every frame is repainting. A blue border means the element has its own compositor layer.
- Record a trace. In the Performance panel, look for repeated Layout or Recalculate Style entries at the animation's frame rate — the profiling animations in DevTools guide covers reading that timeline in depth.
- Emulate reduced motion. In the Rendering panel set Emulate CSS media feature prefers-reduced-motion to
reduceand confirm every state and message is still conveyed.
Spec Reference: CSS Will Change Module Level 1, CSS Transforms Level 2
Browser Support & Progressive Enhancement
| Feature | Support | Notes |
|---|---|---|
@keyframes and the animation shorthand | Universal across current engines | Unprefixed everywhere in current use |
animation-fill-mode | Universal across current engines | Ships with the rest of the module |
:has() for parent-state routing | Chrome 105+, Edge 105+, Safari 15.4+, Firefox 121+ | Needed for the --state routing pattern above |
@property for typed animatable tokens | Chrome 85+, Edge 85+, Safari 16.4+, Firefox 128+ | Makes custom properties interpolable |
linear() easing | Chrome 113+, Edge 113+, Safari 17.2+, Firefox 112+ | Spring-like curves without JavaScript |
animation-timeline: scroll() | Chrome 115+, Edge 115+, Safari 26+ | Still unshipped in Firefox — available only behind a flag |
prefers-reduced-motion | Chrome 74+, Firefox 63+, Safari 10.1+, Edge 79+ | Required for WCAG 2.1 AA conformance |
animation-composition, which controls whether an animation's transform replaces, adds to, or accumulates on the underlying value, is available in current Chrome, Edge, Safari, and Firefox but is recent enough that it is worth treating as an enhancement rather than a baseline.
Experimental timelines should be progressively enhanced with @supports:
@supports (animation-timeline: scroll()) {
.scroll-driven-element {
animation: fade-in linear both;
animation-timeline: scroll(root);
}
}
/* Fallback for unsupported browsers */
@supports not (animation-timeline: scroll()) {
.scroll-driven-element {
animation: fade-in 1s ease-out forwards;
}
}
The full treatment of that trade-off, including how to avoid content that never appears when the timeline is unsupported, is in the scroll-driven animations guide.
Common pitfalls
| Pitfall | Cause | Resolution |
|---|---|---|
| Element snaps back when the animation ends | animation-fill-mode defaults to none | Use forwards or both, or make the final keyframe match the resting style |
| Animation silently does nothing | animation-name does not match any @keyframes, or a later rule replaced it | Check animation-name in the Computed pane and confirm which @keyframes rule the Styles pane shows |
| Element drifts instead of holding | No second keyframe repeating the held values | Add a flat segment: declare identical values at both ends of the hold |
| Only the first segment is eased correctly | One timing function on the shorthand applies to every segment | Declare animation-timing-function inside individual keyframe blocks |
| Loop stutters at the seam | 0% and 100% values differ, or a paint-bound property is animated | Make the endpoints identical, and restrict the loop to transform and opacity |
| Battery drain with nothing visible | Infinite animations continue off-screen | Add content-visibility: auto to off-screen sections and contain: paint to animating containers |
FAQ
How do I prevent keyframe animations from causing layout thrashing?
Restrict animated properties to transform, opacity, and filter. These are handled by the GPU compositor. Avoid animating width, height, top, or left, which trigger expensive layout recalculations. If dimensional changes are required, use transform: scale() and adjust transform-origin accordingly.
Can I chain multiple @keyframes without JavaScript?
Yes. Use animation-delay offsets, CSS custom properties to control iteration counts, or the modern animation-timeline API for scroll-driven sequencing. For sequential playback on a single element, define multiple animations in the animation shorthand: animation: slideIn 0.3s forwards, fadeOut 0.3s 0.3s forwards;.
Why does my element snap back to its original position when the animation ends?
Because animation-fill-mode defaults to none, so the animated values stop applying the instant the animation finishes. Set animation-fill-mode: forwards to retain the final keyframe, or better, make the final keyframe match a class-applied resting state.
What happens if I define two @keyframes rules with the same name?
The later rule wins completely and the earlier one is discarded. Keyframes with the same name do not merge the way ordinary declarations cascade, so duplicating a name in a second stylesheet silently replaces the original animation.
How should keyframe architectures handle prefers-reduced-motion?
Wrap complex sequences in @media (prefers-reduced-motion: reduce) and replace them with instant state changes using opacity or transform: none. Never disable motion entirely; simplify it. Provide a direct visual state change that maintains usability without vestibular triggers.
Related
- CSS-Only Micro-Interactions & Animations — the parent guide tying transitions, keyframes, and accessibility together.
- CSS-only toggle switches and checkboxes — animating form controls from their native
:checkedstate. - CSS-only loading spinners and skeletons — script-free loading feedback built from a single keyframe.
- Staggered list animations with custom properties — drive per-item delay offsets with a
--ivariable. - Container-query-triggered keyframe animations — fire animations based on a component's own width.
- CSS-only accordions and disclosure widgets — animating a native
detailselement open and closed. - How to use container queries in production — the container-query foundation those size-aware animations build on.
Related articles
More pages in the same section.