CSS-Only Micro-Interactions & Animations: Architecture, Performance & Implementation
Interface motion is no longer a decorative afterthought layered on with a JavaScript library. The modern browser ships a complete, hardware-accelerated motion engine that runs declaratively in the stylesheet, survives script failure, and is cheap enough to deploy across an entire design system. This guide for CSS-Only Micro-Interactions & Animations establishes the mental model and the architectural patterns needed to ship motion that stays at 60fps, degrades gracefully, and honours user preferences by default. It is the companion to the container queries and responsive layouts guide — together they cover how a component adapts its shape and how it moves.
CSS-only motion wins over scripted motion in the common case for three concrete reasons. It is declarative, so the browser owns scheduling and can run qualifying animations off the main thread. It is resilient, because a :hover or :checked transition still works when a script throws or has not yet loaded. And it is composable, because timing, easing, and duration live in custom properties that the whole system inherits. JavaScript and the Web Animations API earn their place only when you need runtime-computed values, fine playback control, or sequencing that no CSS state can express — a decision examined in the guide to CSS animation versus the Web Animations API.
By the end of this guide you will understand:
- How the browser splits motion work between the main thread and the compositor thread, and why that split dictates which properties you may animate.
- The canonical syntax for
transition,@keyframes, and theanimation-*family, token by token. - Three reusable architectural patterns — state-driven transitions, a token-driven motion scale, and orchestrated keyframe sequences — each as a complete, annotated block.
- How motion integrates with custom properties,
@property,@layer, container and style queries, andprefers-reduced-motion.
The Core Mental Model: Two Threads, One Frame
Every smooth interface decision flows from one fact about how browsers render: motion can run on the main thread or on the compositor thread, and only one of those keeps up under load. The main thread is where the browser parses CSS, recalculates styles, runs layout, paints pixels, and executes your JavaScript. The compositor thread runs in parallel on the GPU and does one job — it takes already-painted layers and stitches them into a frame, optionally transformed and faded.
When you animate a property that changes an element's geometry — width, height, top, left, margin, padding — the browser must re-run layout for that element and often its siblings, then repaint the affected region, on every single frame. All of that happens on the main thread, in direct competition with any script that is running. When the main thread is busy parsing JSON or hydrating a component, the animation simply skips frames.
Animating transform and opacity is fundamentally different. If the element sits on its own compositor layer, the browser paints it once, hands the layer to the compositor, and from then on each frame is just a matrix multiply and an alpha blend on the GPU. No layout, no paint, no main-thread involvement. This is why the single most important rule of CSS motion is: animate transform and opacity, not box-model properties. The deeper mechanics of layer promotion and the will-change hint are covered in the performance and GPU acceleration guide, with a focused treatment of 60fps optimisation.
A layer is created when an element is promoted — by an active transform animation, will-change: transform, opacity below 1 with compositing, certain filter values, or an explicit translateZ(0). Promotion costs GPU memory, so it is a budget, not a free win. The art is promoting exactly the elements that animate, for exactly as long as they animate, and no more.
Syntax Reference: transition, @keyframes, and animation-*
CSS exposes two motion primitives. A transition interpolates a property when its value changes in response to a state. A keyframe animation plays an authored timeline that can loop, run on load, and pass through intermediate stops. They share an easing and timing vocabulary.
The transition shorthand
.target {
/* property | duration | timing-function | delay */
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1) 0ms;
/* Or expanded, which is clearer for multiple properties */
transition-property: transform, opacity; /* never leave this as `all` */
transition-duration: 240ms; /* time per property */
transition-timing-function: ease-out; /* the easing curve */
transition-delay: 0ms; /* wait before starting */
transition-behavior: allow-discrete; /* opt discrete props in */
}
Listing transition-property explicitly matters: the default of all makes the browser watch every animatable property for change, which interpolates things you never intended and wastes recalculation. The full grammar of curves — ease, linear, cubic-bezier(), and the underrated steps() — is unpacked in CSS transition fundamentals and its companion on transition timing functions.
@keyframes and the animation-* family
@keyframes rise {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.entrance {
/* name | duration | easing | delay | count | direction | fill */
animation: rise 320ms ease-out 0ms 1 normal both;
/* Expanded form, one line per dimension of control */
animation-name: rise;
animation-duration: 320ms;
animation-timing-function: ease-out;
animation-delay: 0ms;
animation-iteration-count: 1; /* a number, or `infinite` */
animation-direction: normal; /* normal | reverse | alternate */
animation-fill-mode: both; /* hold first/last frame values */
animation-play-state: running; /* running | paused */
}
The token most often misunderstood is animation-fill-mode. Without it, an element snaps back to its un-animated style the instant the timeline ends. forwards holds the final keyframe, backwards applies the first keyframe during any delay, and both does both — almost always what you want for an entrance that should stay put.
| Token | Accepted values | Default |
|---|---|---|
animation-duration | <time> (e.g. 320ms, 0.32s) | 0s |
animation-timing-function | ease · linear · ease-in/out · cubic-bezier() · steps() | ease |
animation-iteration-count | <number> · infinite | 1 |
animation-direction | normal · reverse · alternate · alternate-reverse | normal |
animation-fill-mode | none · forwards · backwards · both | none |
transition-behavior | normal · allow-discrete | normal |
Architectural Pattern 1: State-Driven Transitions
The first pattern is the backbone of CSS-only interactivity: bind motion to a native pseudo-class so the browser, not a script, owns the state. No event listeners, no class toggling, and the feedback survives a JavaScript failure entirely. Pair :hover with :focus-visible so keyboard users receive identical feedback, and neutralise hover on coarse pointers so a tap does not strand a card in its hovered state. State-driven design across input modalities is the subject of the hover and focus state design guide and its walkthrough of smooth hover effects without JavaScript.
.card {
/* Only compositor-friendly properties are listed — never `all`. */
transition:
transform 240ms var(--ease-out, ease-out),
box-shadow 240ms var(--ease-out, ease-out);
transform: translateY(0);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.12);
}
/* Hover and keyboard focus share one rule, guaranteeing parity. */
.card:hover,
.card:focus-visible {
transform: translateY(-4px);
box-shadow: 0 10px 24px rgb(0 0 0 / 0.18);
}
/* On touch devices :hover can stick after a tap — opt out cleanly. */
@media (hover: none) and (pointer: coarse) {
.card:hover { transform: none; box-shadow: 0 1px 3px rgb(0 0 0 / 0.12); }
}
The state lives in the DOM, so the transition is reversible for free: when the pointer leaves or focus moves on, the same curve plays in reverse back to the base value. This bidirectionality is something scripted toggles must re-implement by hand.
Architectural Pattern 2: Token-Driven Motion Scale
A design system that lets every component invent its own durations and curves drifts into incoherence. The remedy is to centralise motion in custom properties — a single source of truth for duration, easing, and distance — and have components consume the tokens rather than literal values. This is the same discipline that governs spacing and colour, applied to time, and it is detailed in the CSS custom properties architecture guide. Because a token is just a value, a duration token can even be derived from the fluid spacing scale described in the container queries pillar's fluid typography guide, tying how far an element travels to how big it is.
:root {
/* One motion language for the whole system. */
--motion-fast: 150ms;
--motion-base: 240ms;
--motion-slow: 400ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1);
--lift-sm: -2px;
--lift-md: -4px;
}
.button {
transition: transform var(--motion-fast) var(--ease-standard);
}
.button:hover,
.button:focus-visible { transform: translateY(var(--lift-sm)); }
.panel {
transition: transform var(--motion-base) var(--ease-emphasized);
}
.panel[data-open] { transform: translateY(var(--lift-md)); }
/* A single global switch dials the entire system to near-instant. */
@media (prefers-reduced-motion: reduce) {
:root {
--motion-fast: 1ms;
--motion-base: 1ms;
--motion-slow: 1ms;
}
}
The reduced-motion override is the quiet payoff: because every component reads the duration tokens, redefining them once at the :root calms the entire interface without touching a single component rule.
Architectural Pattern 3: Orchestrated Keyframe Sequences
When motion is multi-step or self-running — a loader, a skeleton shimmer, a staggered list reveal — transitions are not enough and authored keyframes take over. The orchestration technique is to keep one shared @keyframes definition and vary only the per-item animation-delay through a custom property, so the cascade does the staggering. The full taxonomy of these patterns lives in the keyframe animation patterns guide, alongside dedicated builds such as CSS-only toggle switches and checkboxes.
@keyframes rise-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.stagger > * {
/* `both` holds the start frame during the delay and the end frame after. */
animation: rise-in var(--motion-base, 240ms) var(--ease-emphasized, ease-out) both;
/* Each item reads its index from an inline custom property. */
animation-delay: calc(var(--i, 0) * 60ms);
}
/* In markup: <li style="--i:0">…</li> <li style="--i:1">…</li> … */
/* Honour reduced motion by collapsing the stagger to a single frame. */
@media (prefers-reduced-motion: reduce) {
.stagger > * { animation-duration: 1ms; animation-delay: 0ms; }
}
Because the index is just a number on each element, the same sequence scales from three items to thirty with no extra rules — the calc() does the arithmetic the cascade would otherwise force you to write by hand with :nth-child().
Integration with Adjacent CSS
Motion rarely lives alone. Its real power emerges when it composes with the rest of the modern stylesheet.
Custom properties and @property
A plain custom property is an untyped string, so the browser cannot interpolate it — animating --angle from 0deg to 180deg snaps instantly. Registering it with @property gives it a type, an initial value, and inheritance rules, which unlocks smooth interpolation of gradients, angles, and colours that previously demanded JavaScript.
@property --angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.swatch {
background: linear-gradient(var(--angle), #3b82f6, #8b5cf6);
transition: --angle 600ms ease;
}
.swatch:hover { --angle: 180deg; }
@layer for predictable overrides
Place motion rules in a named cascade layer so a third-party component library cannot silently win a specificity battle and break a transition. A layer keeps your motion authoritative without resorting to !important.
@layer base, components, motion;
@layer motion {
.card { transition: transform var(--motion-base) var(--ease-standard); }
}
Container and style queries
A component can change how it moves based on the space it occupies, not the viewport — the bridge between this guide and the container queries and responsive layouts guide. A style query can also gate an animation on a custom property's computed value, letting a state token start or stop motion with no script.
.media { container-type: inline-size; }
@container (min-width: 480px) {
.media__caption {
transition: transform var(--motion-base) var(--ease-emphasized);
}
.media:hover .media__caption { transform: translateY(-6px); }
}
/* Start a loop only when a token says the component is busy. */
@container style(--state: loading) {
.media__spinner { animation: spin 800ms linear infinite; }
}
prefers-reduced-motion as a first-class input
Reduced motion is not a fallback bolted on at the end — it is an environment signal as legitimate as viewport width, and it belongs in the architecture from the start. The accessibility dimension is treated in full in the accessibility in CSS animations guide, including reducing motion preferences in CSS and creating accessible focus indicators.
Progressive Enhancement Strategy
Treat motion as an enhancement layer over a fully functional static interface. The base styles must read and operate with no animation at all; motion is then added behind feature and preference queries so unsupported or motion-averse environments simply receive the calm baseline.
/* 1. Baseline: fully usable with zero motion. */
.dialog {
opacity: 1;
transform: none;
}
/* 2. Enhancement: motion only where it is both supported and welcome. */
@media (prefers-reduced-motion: no-preference) {
.dialog {
transition:
opacity var(--motion-base) var(--ease-standard),
transform var(--motion-base) var(--ease-standard),
overlay var(--motion-base) allow-discrete,
display var(--motion-base) allow-discrete;
}
/* Animate in from a defined entry state (Chrome/Edge 117+, Safari 17.4+, FF 129+). */
@starting-style {
.dialog[open] { opacity: 0; transform: translateY(8px); }
}
}
/* 3. Guard newer features behind @supports so old engines skip them. */
@supports not (transition-behavior: allow-discrete) {
.dialog { transition: opacity var(--motion-base) var(--ease-standard); }
}
This three-tier structure — static base, preference-gated enhancement, feature-gated cutting edge — means a 2019 browser, a current browser, and a user with vestibular sensitivity each get the best experience their environment can support. The discrete-transition technique itself is covered in transitioning display with allow-discrete.
Browser Support & Specification Status
| Feature | Chrome/Edge | Safari | Firefox |
|---|---|---|---|
transition / @keyframes / animation-* | 26+ | 9+ | 16+ |
transform / opacity compositing | 36+ | 9+ | 16+ |
prefers-reduced-motion | 74+ | 10.1+ | 63+ |
:focus-visible | 86+ | 15.4+ | 85+ |
@property typed custom properties | 85+ | 16.4+ | 128+ |
transition-behavior: allow-discrete + @starting-style | 117+ | 17.4+ | 129+ |
Scroll-driven animation-timeline | 115+ | 26+ | Behind a flag |
Style queries @container style() | 111+ | 18+ | Not yet stable |
Verify current support against caniuse.com before shipping, and wrap any feature without universal support in @supports. The baseline primitives — transitions, keyframes, transform, and opacity — are safe everywhere; treat @property, discrete transitions, and scroll-driven animations as enhancements.
Common Issues & Mitigations
| Issue | Cause | Mitigation |
|---|---|---|
| Animation drops frames under load | Animating width, height, top, or margin forces layout and paint on the main thread every frame. | Animate transform and opacity only. Replace size changes with scale() and position changes with translate(). |
| One-frame hitch when an animation starts | The element is promoted to a compositor layer only at the first frame. | Add will-change: transform just before the animation begins and remove it when finished — never leave it on permanently. |
| Custom property animation snaps instead of easing | An unregistered custom property is an untyped string the browser cannot interpolate. | Register it with @property and a typed syntax such as <angle> or <color>. |
| Focus ring vanishes during a transition | A blanket outline: none or a transition that fades the outline removes the visible focus indicator. | Keep a visible :focus-visible outline and never transition it out; preserve outline-offset. |
| Hover state sticks after a tap on mobile | Touch devices emulate :hover, which latches until the next interaction. | Neutralise hover under @media (hover: none) and (pointer: coarse). |
| Element jumps back at animation end | animation-fill-mode defaults to none, discarding the final keyframe. | Set animation-fill-mode: forwards or both to hold the end state. |
Frequently Asked Questions
When should I reach for CSS instead of JavaScript for an animation? Use CSS when motion is declarative and state-driven: hover, focus, toggles, loaders, and entrance reveals. Reach for JavaScript and the Web Animations API only when you need runtime-computed values, complex sequencing with playback control, or animations driven by data that does not map cleanly to a CSS state.
Why do transform and opacity animate smoothly while width and top stutter?transform and opacity can be handled entirely on the compositor thread without re-running layout or paint, so they animate even when the main thread is busy. Animating width, height, top, or left forces layout and paint on every frame, which competes with JavaScript on the main thread and drops frames.
Does will-change make my animations faster?
Only when used sparingly. will-change promotes an element to its own compositor layer ahead of time, which avoids a one-frame hitch at animation start. Applying it to many elements wastes GPU memory and can slow the page, so add it just before an animation and remove it when finished.
How do I respect users who prefer reduced motion?
Wrap non-essential motion so it is disabled under @media (prefers-reduced-motion: reduce). Replace movement with an instant state change or a short opacity fade rather than removing the feedback entirely, and never gate essential information behind an animation.
Can I animate a CSS custom property smoothly?
Only if you register it with @property and give it a typed syntax such as <length>, <color>, or <number>. An unregistered custom property is treated as a plain string, so the browser cannot interpolate between values and the change snaps instantly.
How do I animate an element to and from display: none?
Add transition-behavior: allow-discrete and pair it with @starting-style to define the entry values. This lets display and other discrete properties participate in a transition, supported in Chrome and Edge 117+, Safari 17.4+, and Firefox 129+.
Related
- CSS Transition Fundamentals — the easing, duration, and property grammar behind every state change.
- Keyframe Animation Patterns — authored timelines for loaders, shimmers, and staggered reveals.
- Hover & Focus State Design — pseudo-class-driven feedback with input-modality parity.
- Accessibility in CSS Animations — reduced-motion, focus visibility, and WCAG-aligned motion.
- CSS Custom Properties Architecture — centralising duration and easing tokens for one motion language.
- Performance & GPU Acceleration — compositor layers,
will-change, and holding 60fps. - CSS Animation vs the Web Animations API — when declarative CSS gives way to scripted control.
- Mastering Container Queries & Responsive Layouts — the companion guide on how components adapt their shape to available space.
Guide sections
Browse the next sections in this guide.
- Accessibility in CSS Animations: Patterns, Specs & Best Practices
- CSS Animation vs the Web Animations API: A Decision Guide
- CSS Custom Properties Architecture: Scalable Design Systems & Dynamic UI
- CSS Transition Fundamentals: Architecture, Performance & Patterns
- Hover & Focus State Design: Spec-Compliant Patterns for Modern UIs
- Keyframe Animation Patterns: Spec-Compliant Architectures for Modern UIs
- Performance & GPU Acceleration in CSS: A Developer’s Blueprint