Frontend Visual : Micro-Interactions
Authoritative reference for hover / focus / press feedback, motion choreography, and modern-CSS entry animations. The four primitives : explicit easing curves, compositor-only properties (transform, opacity, filter), prefers-reduced-motion collapse, and @starting-style + transition-behavior: allow-discrete for discrete-property transitions.
Quick Reference
Baseline status
| Feature | Baseline | Source |
|---|---|---|
transition, @keyframes, animation |
Widely Available | MDN : transition (verified 2026-05-19) |
prefers-reduced-motion |
Widely Available since January 2020 | MDN : prefers-reduced-motion (verified 2026-05-19) |
@starting-style |
Newly Available since August 2024 | MDN : @starting-style (verified 2026-05-19) |
transition-behavior: allow-discrete |
Newly Available since August 2024 | MDN : transition-behavior (verified 2026-05-19) |
Timing tokens (memorize these)
| Token | Duration | Use |
|---|---|---|
--motion-instant |
100 ms | press feedback, snap |
--motion-fast |
150 ms | hover, focus on small UI |
--motion-base |
200 ms | standard UI transition |
--motion-medium |
250 ms | entrance / pop |
--motion-slow |
300 ms | large surface enter / drawer slide |
| (avoid > 400 ms) | feels sluggish for utility motion |
Easing curves (memorize these)
| Curve | Value | Use |
|---|---|---|
| Material standard | cubic-bezier(0.2, 0, 0, 1) |
default ease-out for hover, focus, state change |
| Smooth pop | cubic-bezier(0.16, 1, 0.3, 1) |
entrances, list-item reveal |
| Linear | linear |
fixed-distance mechanical feedback, progress bars |
| Spring (approximation) | via @keyframes with overshoot |
playful entrance, NEVER on destructive actions |
NEVER write the bare ease keyword. The default cubic-bezier(0.25, 0.1, 0.25, 1) is visually flat and is the single biggest UX tell for "AI-generated CSS."
Decision Trees
Tree 1 : Which easing for this interaction?
Is it standard UI feedback (hover, focus, color change, opacity fade)?
-> Material standard. cubic-bezier(0.2, 0, 0, 1), 150 to 200 ms.
Is it an entrance (popover open, toast appear, card slide-in)?
-> Smooth pop. cubic-bezier(0.16, 1, 0.3, 1), 200 to 250 ms.
Is it mechanical / fixed-distance (progress bar fill, slider drag)?
-> linear, 100 to 150 ms.
Should it feel playful (illustration reveal, success celebration)?
-> @keyframes with overshoot. NEVER for destructive actions
(delete, sign-out, lock). NEVER for serious-tone surfaces.
Default fallback when unsure?
-> Material standard, 200 ms. Safe everywhere except spring contexts.
Tree 2 : Stagger or parallel?
N items entering together at once (list reveal, card grid populate)?
-> Stagger. transition-delay: calc(var(--index) * 50ms);
Cap total stagger at ~400 ms (8 items at 50 ms). Beyond that, animation feels slow.
N items reacting to the same parent state (form validation, table row highlight)?
-> Parallel. No delay. They all share the trigger so they should resolve together.
One element animating multiple properties (transform AND opacity)?
-> Parallel within the element. Same duration, same easing.
transition: transform 200ms ease-out, opacity 200ms ease-out;
Tree 3 : prefers-reduced-motion collapse
Is the motion essential to convey information (a loading spinner, a progress bar)?
YES -> keep, but reduce amplitude (slow pulse instead of spinning, opacity
fade instead of scale).
NO -> next
Is the motion decorative or feedback (hover scale, entrance slide, micro-bounce)?
YES -> inside @media (prefers-reduced-motion: reduce), collapse to
opacity-only or remove entirely.
NO -> next
Is the motion vestibular-risk (parallax, large pan, zoom, multi-axis spin)?
YES -> inside reduce, REMOVE entirely. Replace with static.
How to detect in JS?
-> window.matchMedia('(prefers-reduced-motion: reduce)').matches
Patterns
Pattern A : Button press feedback (canonical)
.btn {
background: var(--accent);
color: var(--accent-fg);
border: 0;
border-radius: 6px;
padding-block: 0.5rem;
padding-inline: 1rem;
cursor: pointer;
transition:
transform 100ms cubic-bezier(0.2, 0, 0, 1),
background-color 150ms cubic-bezier(0.2, 0, 0, 1),
box-shadow 150ms cubic-bezier(0.2, 0, 0, 1);
}
.btn:hover { background: color-mix(in oklch, var(--accent), white 8%); }
.btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn:active { transform: scale(0.97); }
@media (prefers-reduced-motion: reduce) {
.btn { transition: background-color 100ms linear; }
.btn:active { transform: none; }
}
The press compression (scale(0.97)) is the haptic-like cue that makes a button feel "good to click." For card-sized surfaces, use scale(0.99); for icon-only buttons, scale(0.95).
Pattern B : Card hover with :has() parent reactivity
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem;
transition:
transform 200ms cubic-bezier(0.2, 0, 0, 1),
box-shadow 200ms cubic-bezier(0.2, 0, 0, 1);
}
/* Parent reacts to any descendant button hover */
.card:has(.card-cta:hover) {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgb(0 0 0 / 0.08);
}
.card:has(:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.card:has(.card-cta:hover) { transform: none; box-shadow: 0 0 0 2px var(--accent); }
}
Pattern C : Staggered list entrance via --index
<ul class="list">
<li style="--index: 0">First</li>
<li style="--index: 1">Second</li>
<li style="--index: 2">Third</li>
<li style="--index: 3">Fourth</li>
</ul>
.list li {
opacity: 0;
transform: translateY(8px);
transition:
opacity 250ms cubic-bezier(0.16, 1, 0.3, 1),
transform 250ms cubic-bezier(0.16, 1, 0.3, 1);
transition-delay: calc(var(--index, 0) * 50ms);
}
.list.is-revealed li {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.list li {
transition: opacity 150ms linear;
transform: none;
transition-delay: 0ms;
}
}
Trigger the entrance by adding .is-revealed (e.g., via IntersectionObserver). Stagger total = N * 50 ms; cap below 400 ms.
Pattern D : Animate display: none -> visible via @starting-style + allow-discrete
.tooltip {
/* Default (closed / hidden) state */
opacity: 0;
transform: translateY(4px);
display: none;
transition:
opacity 200ms cubic-bezier(0.16, 1, 0.3, 1),
transform 200ms cubic-bezier(0.16, 1, 0.3, 1),
display 200ms allow-discrete;
}
.tooltip.is-open {
opacity: 1;
transform: translateY(0);
display: block;
}
/* Entrance starting values : applied for first transition only */
@starting-style {
.tooltip.is-open {
opacity: 0;
transform: translateY(4px);
}
}
@media (prefers-reduced-motion: reduce) {
.tooltip {
transition: opacity 100ms linear, display 100ms allow-discrete;
transform: none;
}
@starting-style { .tooltip.is-open { opacity: 0; transform: none; } }
}
Critical : @starting-style MUST come AFTER the .tooltip.is-open rule in source order (same specificity, source order decides). The transition-behavior: allow-discrete shorthand built into the display 200ms allow-discrete syntax tells the browser to flip display at the right point during the transition (immediately on enter, after exit duration).
Pattern E : Keyboard parity for every hover style
.link {
color: var(--fg-muted);
transition: color 150ms cubic-bezier(0.2, 0, 0, 1);
}
.link:hover,
.link:focus-visible {
color: var(--accent);
}
Always pair :hover with :focus-visible. Selector list (:hover, :focus-visible) keeps the rules in one place and prevents drift.
Pattern F : Color animation in OKLCH for perceptual uniformity
.banner {
background: oklch(60% 0.18 250);
transition: background-color 200ms cubic-bezier(0.2, 0, 0, 1);
}
.banner.is-warning {
background: oklch(70% 0.15 60);
}
Per [[frontend-syntax-css-color-modern]], color interpolation in OKLCH avoids the muddy gray transitions of rgb() because OKLCH is perceptually uniform.
Out of Scope
- Scroll-driven animations and View Transitions API (covered in
[[frontend-impl-view-transitions-scroll-animations]]). - Compositor-only / GPU-friendly animation rules (covered in
[[frontend-perf-animation-gpu-containment]]). - Web Animations API JavaScript (
Element.animate()); this skill is CSS-first. - Framework animation libraries (Framer Motion, GSAP, Anime.js). This package is framework-agnostic.
Hard Rules (Binding)
- NEVER use the bare
easekeyword. Always specify acubic-bezier(...)or uselinearfor fixed-distance motion. - NEVER write
transition: all. List explicit composite-safe properties (transform,opacity, color-only properties). - NEVER animate layout-trigger properties (
width,height,top,left,margin,padding,font-size,box-shadow) for hover / focus / press feedback. Usetransformandopacity. - NEVER ship motion without a
@media (prefers-reduced-motion: reduce) { ... }override. WCAG 2.3.3 Animation from Interactions applies. - NEVER style
:hoverwithout an equivalent:focus-visiblerule. Keyboard users need the same feedback. - NEVER set bouncy / spring easing on destructive actions (Delete, Sign-out, Lock). The tone mismatch reads as untrustworthy.
- NEVER exceed 400 ms for utility transitions. Beyond that, the UI feels sluggish. Reserve longer durations for large-surface entrances and only inside the
prefers-reduced-motion: no-preferencebranch. - NEVER place
@starting-styleBEFORE its target rule. Same specificity = source order decides; the starting style must come after.
Reference Links
references/methods.md: easing-curve catalog, timing tokens,@starting-style+allow-discretemechanics, stagger mathreferences/examples.md: renderable HTML demo with button press, card:has()hover, staggered list (with reduced-motion variant)references/anti-patterns.md: 7 anti-patterns with symptom, root cause, fix- MDN : transition (verified 2026-05-19)
- MDN : animation (verified 2026-05-19)
- MDN : @starting-style (verified 2026-05-19)
- MDN : transition-behavior (verified 2026-05-19)
- MDN : prefers-reduced-motion (verified 2026-05-19)
Cross-References
[[frontend-perf-animation-gpu-containment]]: compositor-only properties,will-changecycle[[frontend-errors-animation-jank]]: diagnosing janky transitions in DevTools[[frontend-syntax-css-has-selector]]: parent-reactive choreography[[frontend-syntax-css-color-modern]]: OKLCH for perceptually-uniform color animation[[frontend-a11y-motion-contrast-wcag22]]: WCAG 2.3.3 and 2.2.2 motion criteria[[frontend-impl-view-transitions-scroll-animations]]: larger-scope animation primitives