Animation should communicate state change — entering, leaving, loading, error. Everything else is visual noise that slows down the interface and annoys users who've turned on reduced motion. The craft is knowing when to animate and, more importantly, when not to.
CSS transitions are the simplest tool — change a property and the browser interpolates between the old and new values. No JavaScript required. Ideal for hover states, colour changes, and layout shifts that need to feel smooth rather than instant.
CSS animations use @keyframes for multi-step sequences. More control than transitions, still zero JS. Good for loading spinners, skeleton pulses, and anything that loops.
The Web Animations API gives JavaScript control over the same engine that runs CSS animations. You get .play(), .pause(), .reverse(), and promises that resolve when the animation finishes. Useful when you need to coordinate animations with logic.
Framer Motion (React) and similar libraries add spring physics, layout animations, gesture handling, and enter/exit transitions via AnimatePresence. They're the right tool when CSS can't express what you need — and overkill when it can.
/* CSS transition — the 80% solution */ .card { transform: translateY(0); opacity: 1; transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease; } .card:hover { transform: translateY(-4px); } /* IntersectionObserver fade-in — this site uses this pattern */ .fade-up { opacity: 0; transform: translateY(24px); transition: opacity 0.6s ease, transform 0.6s ease; } .fade-up.visible { opacity: 1; transform: translateY(0); }
// Framer Motion — enter/exit with spring physics import { motion, AnimatePresence } from 'framer-motion' const variants = { hidden: { opacity: 0, y: 20, scale: 0.95 }, visible: { opacity: 1, y: 0, scale: 1 }, exit: { opacity: 0, y: -10, scale: 0.95 }, } function Toast({ message, isVisible }) { return ( <AnimatePresence> {isVisible && ( <motion.div variants={variants} initial="hidden" animate="visible" exit="exit" transition={{ type: "spring", stiffness: 300, damping: 25 }} > {message} </motion.div> )} </AnimatePresence> ) }
// IntersectionObserver — vanilla JS fade-in on scroll const observer = new IntersectionObserver( (entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('visible'); } }); }, { threshold: 0.1, rootMargin: '0px 0px -40px 0px' } ); document.querySelectorAll('.fade-up').forEach(el => observer.observe(el));
| Tool | Best for | Avoid when |
|---|---|---|
CSS transitions | Hover, focus, simple state changes | Enter/exit animations, complex sequences |
CSS @keyframes | Loops, loading states, skeleton pulses | Animations that need JS coordination |
Web Animations API | Programmatic control, chaining, cancelling | Simple hover effects (use CSS) |
Framer Motion | Enter/exit, layout, gestures, springs | Non-React projects, simple CSS-solvable motion |
Some users get nauseous from parallax and sliding animations. Others have vestibular disorders. @media (prefers-reduced-motion: reduce) must disable or simplify every animation. This is not optional.
Animating width, height, top, or left triggers layout recalculation on every frame. Stick to transform and opacity — they run on the GPU compositor and don't trigger reflow.
will-change: transform tells the browser to promote an element to its own compositor layer. Applying it to every element wastes GPU memory and can cause blurry text. Use it on elements that will actually animate, and remove it when the animation ends.
A staggered fade-in that takes 2 seconds to reveal the page content is not a "delightful experience" — it's making users wait. Keep entrance animations under 400ms total. Users came for the content, not the choreography.