Animation can bring joy to an interface.
Whether they are animations via interactions (e.g. clicking on a button) or orchestrated (e.g. animating a moving carousel), motion can give the product a way to express itself. Just like the visual style or language it uses, motion can have a certain tone or message it’s trying to convey.
Tip: Push, pull, or hit esc to close the expanded view
The web is traditionally a static, document-based medium. The introduction of the iPhone meant opening up a whole new world of sophisticated animations users are now accustomed to seeing. Even with new APIs and browser capability, this poses challenges on the web.
Below are lessons from adding motion to user interfaces on the web. I’ve learnt a lot of these over the years, particularly when working with React.
Easings & Duration
Easing and duration determine how an animation feels.
This plays an important role in what the animation communicates.
Easing and duration can also influence the perception of speed. The popover on the left below uses ease-in with a slower duration so you can spot the difference. It doesn’t feel right because it starts slow and speeds up.
The one on the right uses ease-out, which has velocity from the start and comes to a gentle stop. This feels correct and the animation feels faster.
To know what easing & duration to choose, it comes down to experimentation. However I’ve found I usually stick to 150ms - 300ms and these easings:
:root { /* Elements enter/leave the viewport */ --ease-out: cubic-bezier(0.215, 0.61, 0.355, 1); /* Elements change position on the viewport */ --ease-in-out: cubic-bezier(0.645, 0.045, 0.355, 1); /* Use linear for constant, keyframe animations */ }
Gestures
Gestures are parts of user interfaces. We use them constantly to trigger events (e.g. clicking a button, hovering over a link). Adding subtle animation can help make these moments feel alive.
Adding active:scale-[0.96] to the button is a low-effort way to make the click feel much more tactile. Beyond just scale, you can layer multiple properties for richer feedback. Combining scale with slight opacity changes (hover:opacity-90), or adding a subtle shadow shift, creates depth and reinforces the interaction.
The key is restraint. Micro-interactions should feel natural, not distracting. A 4% scale reduction on click is noticeable but not jarring. Similarly, hover states should respond immediately (0ms delay) but transition smoothly (around 150ms) to avoid feeling sluggish or hyperactive.
Using clip-path animations
clip-path is an often overlooked CSS function, typically used for drawing shapes around nodes. But it works great for creating interesting animations and interactions. You can stack elements, hiding and revealing them by just changing a value.
Stacking these images and adding clip-path: (0 0 var(--pos) 0) whilst animating the value (e.g. 50%) creates this before/after slider.
Layout animations
Animating layout changes brings interfaces to life, whether that’s swapping content, changing size, or moving position. When done right, these animations maintain visual continuity and help users track what’s happening.
State transitions
Animating transitions between states, such as changing the month on a calendar, requires orchestrating both entrance and exit animations. Framer Motion’s AnimatePresence handles this by tracking when components mount and unmount.
December 2025
Motion is determined by the key prop. When month changes, the component with the old key exits while the new one enters:
<AnimatePresence initial={false}> <motion.div key={month} initial={{ x: 40, opacity: 0, filter: "blur(2px)" }} animate={{ x: 0, opacity: 1, filter: "blur(0px)" }} exit={{ x: -40, opacity: 0, filter: "blur(2px)" }} transition={{ type: "spring", bounce: 0, duration: 0.4 }} > {month} </motion.div> </AnimatePresence>
Size and position
Beyond state changes, animating dimensions and position maintains continuity when layouts shift. Measuring inner content and animating the height value adapts the container nicely to content changes.
const [ref, bounds] = useMeasure(); <motion.div animate={{ height: bounds.height }} transition={{ type: "spring", bounce: 0, duration: 0.4 }} > <div ref={ref} className="inner-content"> ... </div> </motion.div>;
For position changes—like reordering lists or moving elements between containers—the layout prop automatically animates from old to new positions:
<motion.div layout transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}> {content} </motion.div>
Shared element transitions work similarly. Use layoutId to morph elements between different views, like a card expanding from a grid into a detail view:
// Grid view <motion.div layoutId={`card-${id}`}>...</motion.div> // Detail view <motion.div layoutId={`card-${id}`}>...</motion.div>
Orchestration
Orchestration is about choreographing when elements animate in a sequence. Rather than having everything transition linearly at once, orchestration creates layered, intentional timing that guides the user’s attention and makes interfaces feel more alive.
Elements shouldn’t all move at the same speed or time, especially if they’re conceptually distinct or have different sizes. The goal is to create hierarchy and flow through timing—delaying one element’s appearance until another completes, or staggering groups of similar items.
Staggering
A common orchestration technique is staggering. This means slightly delaying each element’s animation based on its order in a group. When done well, staggering creates a ripple effect that feels natural and draws attention sequentially.
- Research user needs
- Create wireframes
- Design high-fidelity mockups
- Build interactive prototype
- Conduct usability testing
- Iterate based on feedback
This list animates in with a stagger delay between items. Each item waits 100ms longer than the previous one:
{ ITEMS.map((item, index) => ( <motion.li initial={{ opacity: 0, x: -20, filter: "blur(2px)" }} animate={{ opacity: 1, x: 0, filter: "blur(0px)" }} transition={{ duration: 0.3, ease: "easeOut", delay: index * 0.1, }} > {item.title} </motion.li> )); }
The same principle works for action buttons. These buttons stagger in with decreasing delays to create a wave effect:
const STAGGER_DELAYS = [0, 0.05, 0.1]; { buttons.map((button, index) => ( <motion.div initial={{ opacity: 0, y: 10, filter: "blur(2px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} transition={{ duration: 0.2, delay: STAGGER_DELAYS[index] }} > <Button>{button.label}</Button> </motion.div> )); }
Keep stagger delays short—usually between 50ms and 150ms. Too long and the interface feels sluggish; too short and the stagger becomes imperceptible. The timing should feel intentional but not labored.
Performance
Not all animations are created equal. The browser can animate transform and opacity cheaply because they don’t trigger layout recalculations—they run on the GPU. Animating properties like width, height, top, or left forces the browser to recalculate layout and repaint, which can cause jank on slower devices.
When possible, use transforms instead:
- Moving elements: Use
translateX/Yinstead ofleft/top - Showing/hiding: Use
opacityandscaleinstead ofdisplayorheight - Complex animations: Add
will-change: transformto hint to the browser, but remove it after completion—leaving it on permanently hurts performance
CSS vs JavaScript
Prioritize CSS animations over JavaScript when possible. CSS animations are more performant because they run on the compositor thread and reduce bundle size. For simple transitions, use CSS transition or @keyframes:
.button { transition: transform 200ms ease-out; } .button:hover { transform: scale(1.05); }
For complex orchestrations or layout animations, animation libraries like Framer Motion or React Spring are worth the trade-off, but keep performance in mind.
Off-screen optimizations
Pause or disable looping animations when elements are off-screen. This conserves CPU and GPU resources, especially for users with many tabs open:
const [isInView, setIsInView] = React.useState(false); <motion.div onViewportEnter={() => setIsInView(true)} onViewportLeave={() => setIsInView(false)} animate={isInView ? { rotate: 360 } : { rotate: 0 }} />;
Theme switching
Avoid triggering transitions during theme changes. When users toggle between light and dark modes, you don’t want every element on the page to animate. Use a class to temporarily disable transitions:
document.documentElement.classList.add("no-transitions"); // Apply theme change setTimeout(() => { document.documentElement.classList.remove("no-transitions"); }, 0);
.no-transitions * { transition: none !important; }
Frame rate
Target 60fps for smooth motion. If animations drop below this, they’ll feel janky. Use browser DevTools to profile performance and identify bottlenecks. Complex animations with many elements may need simplification or optimization to maintain smooth frame rates on lower-powered devices.
Accessibility
Motion isn’t universal. Some users experience nausea, dizziness, or distraction from animations, which is why prefers-reduced-motion exists. Respecting this media query isn’t optional—it’s about making interfaces usable for everyone.
In practice, this doesn’t mean removing all motion. Instead, reduce duration significantly (instant or near-instant), remove bounces and springs, or swap animations for simple fades. The functional outcome (showing/hiding content, transitioning states) remains the same, but the experience becomes comfortable for users who need it.
@media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
Video autoplay
Disable video autoplay for users with prefers-reduced-motion enabled. Autoplaying videos can be particularly disorienting:
const prefersReducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)", ).matches; <video autoPlay={!prefersReducedMotion} />;
Screen readers
Provide alternatives for animated content that conveys meaning. Use aria-hidden for purely decorative animations like loaders or background effects:
<motion.div aria-hidden="true"> {/* Decorative animation */} </motion.div> <span className="sr-only">Loading...</span>
For animations that communicate state changes, ensure screen reader users receive equivalent information through aria-live regions or status updates.
Keyboard focus
Pause looping animations when elements receive keyboard focus. This prevents distraction and makes it easier for keyboard users to understand what they’ve focused on:
<motion.div onFocus={() => setIsPaused(true)} onBlur={() => setIsPaused(false)} animate={isPaused ? {} : { rotate: 360 }} />
Test your interfaces with prefers-reduced-motion: reduce enabled to ensure they still feel coherent without the flourishes.

