Motion is choreography, not decoration. One well-orchestrated page load creates more delight than a dozen scattered effects.
The load sequence
Stagger reveals top-to-bottom, 60–90ms apart:
[data-reveal] {
opacity: 0;
transform: translateY(14px);
transition: opacity .6s cubic-bezier(.2,.7,.2,1),
transform .6s cubic-bezier(.2,.7,.2,1);
transition-delay: var(--reveal-delay, 0ms);
}
[data-reveal].is-revealed { opacity: 1; transform: none; }
const io = new IntersectionObserver((entries) => {
entries.forEach(en => {
if (en.isIntersecting) { en.target.classList.add('is-revealed'); io.unobserve(en.target); }
});
}, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });
document.querySelectorAll('[data-reveal]').forEach((el, i) => {
el.style.setProperty('--reveal-delay', (i % 6) * 70 + 'ms');
io.observe(el);
});
Rules: hero elements animate on load; below-fold elements reveal on scroll; nothing animates twice.
Easing vocabulary
- Standard:
cubic-bezier(.2,.7,.2,1)— fades, reveals, most transitions. - Exit:
cubic-bezier(.4,0,.1,1)— things leaving the screen. - Spring:
cubic-bezier(.34,1.56,.64,1)— playful overshoot ONLY; one or two uses per site. - Durations: micro-interactions 120–200ms; reveals 400–700ms; luxury slows everything ×1.5.
Micro-interactions worth their bytes
- Hover lifts: 2–4px translate + shadow deepen, 150ms.
- Underline grows from left on nav hover.
- Copy buttons flip to "Copied" state for 1.5s.
- Accordion chevrons rotate 45°.
Reduced motion is not optional
@media (prefers-reduced-motion: reduce) {
[data-reveal] { opacity: 1; transform: none; transition: none; }
*, *::before, *::after { animation-duration: .01ms !important; }
}
Replace movement with instant state changes; keep all information accessible.
Anti-patterns
- Parallax on content (decorative layers only, if at all).
- Auto-playing carousels.
- Animating layout properties (width/top) — transform and opacity only.
- Entrance animations on every element ("everything fading in" = nothing landing).