Let's look at how to build hardware-accelerated UI animations that run at a crisp 60fps on both desktop and mobile devices.
What you'll learn:
- How to trigger layout-safe CSS transitions using React state hooks.
- Offloading heavy calculations to
requestAnimationFramefor buttery smooth scrolling. - Creating fluid interactive hover states with proper easing functions.
- Respecting user accessibility preferences for reduced motion.
Structuring Smooth CSS Transitions in React
When building interactive elements like dropdowns, modals, or sliding panels, triggering layout changes via top/left properties forces the browser to recalculate layout constantly. Instead, we want to animate using transform and opacity properties. These run on the compositor thread, keeping the main thread free for JavaScript execution.
Here is a clean component that toggles a lateral slider panel using Tailwind CSS classes for transforms.
import { useState } from 'react';
export function LateralSlider() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative overflow-hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="px-4 py-2 bg-indigo-600 text-white rounded-md font-medium"
>
{isOpen ? 'Close Panel' : 'Open Panel'}
</button>
{/* Backdrop */}
<div
className={`fixed inset-0 bg-black/50 transition-opacity duration-300 ${
isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
}`}
onClick={() => setIsOpen(false)}
/>
{/* Sliding Panel */}
<div
className={`fixed top-0 right-0 h-full w-80 bg-white shadow-2xl p-6 transform transition-transform duration-300 ease-out ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<h2 className="text-xl font-bold mb-4">Settings Menu</h2>
<p className="text-gray-600">Configure your application parameters here.</p>
</div>
</div>
);
}
Speed Note: Always use
will-change: transformsparingly or let the browser handle it via hardware-accelerated classes liketranslate-x-*. Overusingwill-changeexhausts GPU memory on mobile devices.
Building Scroll-Triggered Interactions with Intersection Observer
Scroll animations add polish, but attaching heavy event listeners directly to the scroll event will tank your frame rate. Instead, we use the IntersectionObserver API to monitor elements entering the viewport asynchronously.
Here is a simple React hook wrapper that triggers state changes when an element scrolls into view.
import { useEffect, useRef, useState } from 'react';
export function useScrollReveal(threshold = 0.1) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.unobserve(element); // Trigger only once
}
},
{ threshold }
);
observer.observe(element);
return () => observer.disconnect();
}, [threshold]);
return { ref, isVisible };
}
Gotcha: Always unobserve elements once the animation fires if you don't need re-triggering. Leaving observers active on long pages consumes unnecessary memory.
Assembling Scroll-Animated Cards
Now let's apply our scroll hook to build a card component that fades and slides up smoothly as the user scrolls down the page.
import { useScrollReveal } from './useScrollReveal';
export function ScrollCard({ title, description }: { title: string; description: string }) {
const { ref, isVisible } = useScrollReveal(0.2);
return (
<div
ref={ref}
className={`p-6 bg-slate-900 text-white rounded-xl border border-slate-800 transition-all duration-700 ease-out ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-12'
}`}
>
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-slate-400 text-sm">{description}</p>
</div>
);
}
Accessibility Note: Users prone to motion sickness often enable "Reduce Motion" in their operating system settings. Always check this preference before triggering sweeping transform animations.
Handling Reduced Motion Queries
To respect user accessibility settings, we can read the prefers-reduced-motion media query in CSS or via a custom React hook to disable or tone down heavy transitions.
import { useEffect, useState } from 'react';
export function usePrefersReducedMotion() {
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;
}
By wrapping animation classes conditionally, you ensure your app is accessible to everyone without stripping away the core layout structure.
Summary
Combining CSS transitions for hardware-accelerated movement with Intersection Observer APIs for scroll triggers gives you the best of both worlds: buttery smooth animations and clean, maintainable code. Keep layout calculations off the main thread and always respect user preferences.
Next Steps:
- Audit your current app for layout thrashing by checking Chrome DevTools Performance panel.
- Implement
IntersectionObserverfor lazy-loading heavy components alongside your scroll animations. - Add
prefers-reduced-motionchecks to all custom modal and slider transitions.