Let's look at how to implement buttery-smooth UI animations using modern CSS and the native Web Animations API (WAAPI) while avoiding layout thrashing.
What you'll learn
- Why animating layout properties ruins frame rates.
- How to trigger hardware-accelerated transforms in CSS.
- Controlling timelines dynamically using the Web Animations API.
- Handling accessibility preferences for motion-sensitive users.
1. Stick to Composite-Only Properties
If you animate properties like width, height, top, or left, the browser has to recalculate layout and paint every single frame. That drops your frame rate instantly.
Instead, stick strictly to transform and opacity. These run entirely on the compositor thread.
// styles.css
.card {
/* Bad: triggers layout recalculations */
/* transition: top 0.3s ease; */
/* Good: handled by the GPU compositor */
transform: translateY(0);
opacity: 1;
transition: transform 300ms cubic-bezier(0.16, 1, 0.3, 1),
opacity 300ms ease;
}
.card:hover {
transform: translateY(-4px);
}
Speed Note: Always use
will-change: transformsparingly if an element stutters on initial hover, but avoid applying it globally as it consumes excessive GPU memory.
2. Declarative Enter Animations with Tailwind
For basic UI elements like dropdowns or modals entering the DOM, declarative CSS classes combined with Tailwind keep your component logic clean and maintainable.
// components/Modal.tsx
import React from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, children }: ModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl transition-all animate-scale-in">
{children}
<button
onClick={onClose}
className="mt-4 rounded-lg bg-slate-900 px-4 py-2 text-white hover:bg-slate-800"
>
Close
</button>
</div>
</div>
);
}
3. Dynamic Control with the Web Animations API
When you need precise runtime control—like reversing an animation, scrubbing through timelines, or chaining events—CSS falls short. That is where the Web Animations API shines.
// components/PulseBox.tsx
'use client';
import React, { useRef } from 'react';
export function PulseBox() {
const boxRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<Animation | null>(null);
const handleMouseEnter = () => {
if (!boxRef.current) return;
// Create or play the WAAPI timeline
if (!animationRef.current) {
animationRef.current = boxRef.current.animate(
[
{ transform: 'scale(1)', background: '#3b82f6' },
{ transform: 'scale(1.08)', background: '#60a5fa' },
{ transform: 'scale(1)', background: '#3b82f6' }
],
{
duration: 600,
iterations: Infinity,
easing: 'ease-in-out'
}
);
} else {
animationRef.current.play();
}
};
const handleMouseLeave = () => {
animationRef.current?.pause();
};
return (
<div
ref={boxRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className="h-32 w-32 cursor-pointer rounded-2xl bg-blue-500 shadow-lg"
/>
);
}
Gotcha: Always hold a reference to the returned
Animationobject if you intend to pause, reverse, or cancel it later. Letting animations accumulate unboundedly creates memory leaks.
4. Respecting prefers-reduced-motion
Heavy animations can cause severe physical discomfort or disorientation for users with vestibular disorders. Always check user preferences before triggering motion.
// hooks/useReducedMotion.ts
import { useEffect, useState } from 'react';
export function useReducedMotion(): boolean {
const [reducedMotion, setReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
setReducedMotion(mediaQuery.matches);
const handler = (event: MediaQueryListEvent) => {
setReducedMotion(event.matches);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
return reducedMotion;
}
5. Integrating Motion States Safely in React
Combine your reduced motion hook directly into your animation triggers so components gracefully fall back to instant state changes when requested.
// components/SmoothCard.tsx
'use client';
import React from 'react';
import { useReducedMotion } from '@/hooks/useReducedMotion';
export function SmoothCard({ title }: { title: string }) {
const shouldReduceMotion = useReducedMotion();
const animationClass = shouldReduceMotion
? 'transition-none'
: 'transition-all duration-300 hover:-translate-y-1 hover:shadow-xl';
return (
<div className={`rounded-xl border border-slate-200 bg-white p-6 shadow-sm ${animationClass}`}>
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
<p className="mt-2 text-sm text-slate-600">
This card respects user accessibility settings automatically.
</p>
</div>
);
}
Summary & Next Steps
High-performance web animation is all about offloading work to the GPU compositor thread, leveraging native browser APIs when complexity grows, and respecting user accessibility settings.
- Next Steps:
- Audit your current app for layout-thrashing properties like
heighttransitions. - Implement
useReducedMotionacross all interactive component libraries. - Experiment with the Web Animations API for timeline scrubbing on scroll events.
- Audit your current app for layout-thrashing properties like