UI Animation Patterns — Copy-Paste Reference
Contents
- Motion tokens
- Micro-interaction (button press/hover)
- Enter/exit transition (dialog, toast)
- Scroll reveal with IntersectionObserver
- Page-load sequence (staggered)
- Web Animations API sequencing
- Reduced-motion gate
- Gotchas
Motion tokens
css
:root {
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* enter: fast start, soft landing */
--ease-in: cubic-bezier(0.7, 0, 0.84, 0); /* exit: accelerate away */
--dur-fast: 150ms; /* hover, press feedback */
--dur-base: 250ms; /* enters, exits, toggles */
--dur-slow: 450ms; /* scene-level changes */
}Micro-interaction (button press/hover)
css
.btn {
transition: transform var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0) scale(0.98); }Enter/exit transition (dialog, toast)
Enter with ease-out, exit faster with ease-in.
css
.toast {
transition: transform var(--dur-base) var(--ease-out),
opacity var(--dur-base) var(--ease-out);
}
.toast[data-state="closed"] {
transform: translateY(8px);
opacity: 0;
transition-duration: var(--dur-fast);
transition-timing-function: var(--ease-in);
}Animating from display: none (modern CSS):
css
dialog[open] {
opacity: 1;
transform: none;
transition: opacity var(--dur-base) var(--ease-out),
transform var(--dur-base) var(--ease-out),
display var(--dur-base) allow-discrete;
@starting-style { opacity: 0; transform: translateY(12px); }
}Scroll reveal with IntersectionObserver
Content is fully visible without JS; the class only enables the hidden start state.
html
<section data-reveal>…</section>css
.js [data-reveal] { opacity: 0; transform: translateY(16px); }
.js [data-reveal].is-shown {
opacity: 1; transform: none;
transition: opacity var(--dur-slow) var(--ease-out),
transform var(--dur-slow) var(--ease-out);
}js
document.documentElement.classList.add('js');
const io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) {
e.target.classList.add('is-shown');
io.unobserve(e.target); // reveal once
}
}, { threshold: 0.15 }); // fire when 15% visible
document.querySelectorAll('[data-reveal]').forEach(el => io.observe(el));Page-load sequence (staggered)
One orchestrated moment: hero elements enter in order, 60ms apart.
css
.hero > * {
opacity: 0;
transform: translateY(12px);
animation: enter var(--dur-slow) var(--ease-out) forwards;
animation-delay: calc(var(--i) * 60ms);
}
@keyframes enter { to { opacity: 1; transform: none; } }html
<div class="hero">
<h1 style="--i:0">…</h1>
<p style="--i:1">…</p>
<a style="--i:2" class="btn">…</a>
</div>Web Animations API sequencing
For interruption-safe, runtime-computed motion.
js
const panel = document.querySelector('.panel');
const anim = panel.animate(
[{ transform: 'translateX(100%)' }, { transform: 'none' }],
{ duration: 250, easing: 'cubic-bezier(0.16,1,0.3,1)', fill: 'forwards' }
);
// interruptible: reverse mid-flight instead of restarting
closeBtn.onclick = () => anim.reverse();
await anim.finished; // sequencing pointReduced-motion gate
css
@media (prefers-reduced-motion: reduce) {
[data-reveal], .hero > * { opacity: 1 !important; transform: none !important;
animation: none !important; transition: none !important; }
}js
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduced) el.animate(/* … */);Gotchas
transition: allinvites accidental layout animations and future regressions — always list properties.animation-fill-mode: forwardskeeps the element on its final keyframe, which can trap it in a stale state after class changes; prefer transitioning real end states.will-changeleft permanently on many elements consumes GPU memory — add before heavy animation, remove after.- Staggers multiply duration: 10 items × 100ms delay = a 1s+ sequence. Cap total sequence time near 700ms.
height: autocan't transition; animategrid-template-rows: 0fr → 1fron a wrapper, or use WAAPI with measured pixel values.- IntersectionObserver with
threshold: 1.0never fires on elements taller than the viewport.