Skip to main content

React Hooks for Animation: useAnimations Patterns

Animation logic in React is often scattered across components: refs, useEffect cleanup, refs for storing animation objects, and manual state synchronization. Custom hooks encapsulate animation logic into reusable, composable utilities that feel idiomatic to React. This article teaches you how to build and use custom animation hooks that handle lifecycle management, prevent memory leaks, and integrate animations seamlessly with React state.

The useAnimation Hook

The simplest reusable hook: animate a single element with automatic cleanup:

// useAnimation.js
import { useRef, useEffect } from 'react';

function useAnimation(keyframes, options = {}) {
const elementRef = useRef(null);
const animationRef = useRef(null);

const animate = async (target = elementRef.current) => {
if (!target) return;

// Cancel any running animation
if (animationRef.current) {
animationRef.current.cancel();
}

animationRef.current = target.animate(keyframes, options);
await animationRef.current.finished;
return animationRef.current;
};

useEffect(() => {
return () => {
// Cleanup: cancel animation on unmount
if (animationRef.current) {
animationRef.current.cancel();
}
};
}, []);

return { elementRef, animate, animation: animationRef.current };
}

export default useAnimation;

Use it in a component:

import useAnimation from './useAnimation';

export default function FadeInButton() {
const { elementRef, animate } = useAnimation(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 300 }
);

const handleClick = async () => {
await animate();
};

return (
<button ref={elementRef} onClick={handleClick} style={{ opacity: 0 }}>
Click to fade in
</button>
);
}

This hook eliminates boilerplate: no manual refs, no manual cleanup, just useAnimation and call animate().

The useTransition Hook

Animate an element in response to a boolean state (open/close, show/hide):

// useTransition.js
import { useRef, useEffect, useState } from 'react';

function useTransition(isOpen, keyframesIn, keyframesOut, options = {}) {
const elementRef = useRef(null);
const animationRef = useRef(null);
const [isAnimating, setIsAnimating] = useState(false);

useEffect(() => {
if (!elementRef.current) return;

const animate = async () => {
setIsAnimating(true);

// Cancel any running animation
if (animationRef.current) {
animationRef.current.cancel();
}

const keyframes = isOpen ? keyframesIn : keyframesOut;
animationRef.current = elementRef.current.animate(keyframes, options);

try {
await animationRef.current.finished;
} finally {
setIsAnimating(false);
}
};

animate();

return () => {
if (animationRef.current) {
animationRef.current.cancel();
}
};
}, [isOpen, keyframesIn, keyframesOut, options]);

return { elementRef, isAnimating };
}

export default useTransition;

Use it in a modal:

import { useState } from 'react';
import useTransition from './useTransition';

export default function Modal() {
const [isOpen, setIsOpen] = useState(false);
const { elementRef, isAnimating } = useTransition(
isOpen,
[{ opacity: 0, transform: 'scale(0.9)' }, { opacity: 1, transform: 'scale(1)' }],
[{ opacity: 1, transform: 'scale(1)' }, { opacity: 0, transform: 'scale(0.9)' }],
{ duration: 300, easing: 'ease-out', fill: 'forwards' }
);

return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle Modal</button>
{(isOpen || isAnimating) && (
<div
ref={elementRef}
style={{
padding: '20px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}
>
<p>Modal content</p>
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
)}
</div>
);
}

The modal animates in when isOpen becomes true and animates out when it becomes false. The element remains mounted during the exit animation, so it animates out smoothly before unmounting.

The useHover Hook

Animate an element on hover:

// useHover.js
import { useRef, useState } from 'react';

function useHover(keyframesHover, keyframesLeave, options = {}) {
const elementRef = useRef(null);
const animationRef = useRef(null);

const handleMouseEnter = async () => {
if (animationRef.current) {
animationRef.current.cancel();
}

animationRef.current = elementRef.current.animate(keyframesHover, options);
};

const handleMouseLeave = async () => {
if (animationRef.current) {
animationRef.current.cancel();
}

animationRef.current = elementRef.current.animate(keyframesLeave, options);
};

return {
elementRef,
handlers: { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave },
};
}

export default useHover;

Use it in a button:

import useHover from './useHover';

export default function HoverButton() {
const { elementRef, handlers } = useHover(
[{ transform: 'scale(1)' }, { transform: 'scale(1.1)' }],
[{ transform: 'scale(1.1)' }, { transform: 'scale(1)' }],
{ duration: 200, easing: 'ease-out', fill: 'forwards' }
);

return (
<button
ref={elementRef}
{...handlers}
style={{
padding: '10px 20px',
backgroundColor: '#4ecdc4',
border: 'none',
cursor: 'pointer',
}}
>
Hover me
</button>
);
}

No CSS transitions needed—the animation is driven by JavaScript, giving you full control.

The useSequentialAnimations Hook

Animate multiple elements in sequence:

// useSequentialAnimations.js
import { useRef, useEffect } from 'react';

function useSequentialAnimations(animations = []) {
const animationRefs = useRef([]);

const animateSequence = async () => {
for (const { element, keyframes, options } of animations) {
if (element) {
const animation = element.animate(keyframes, options);
await animation.finished;
}
}
};

useEffect(() => {
return () => {
// Cleanup: cancel all animations
animationRefs.current.forEach((anim) => {
if (anim) anim.cancel();
});
};
}, []);

return { animateSequence };
}

export default useSequentialAnimations;

Use it in a list:

import { useRef } from 'react';
import useSequentialAnimations from './useSequentialAnimations';

export default function LoadingList() {
const item1Ref = useRef(null);
const item2Ref = useRef(null);
const item3Ref = useRef(null);

const { animateSequence } = useSequentialAnimations([
{ element: item1Ref.current, keyframes: [{ opacity: 0 }, { opacity: 1 }], options: { duration: 300 } },
{ element: item2Ref.current, keyframes: [{ opacity: 0 }, { opacity: 1 }], options: { duration: 300 } },
{ element: item3Ref.current, keyframes: [{ opacity: 0 }, { opacity: 1 }], options: { duration: 300 } },
]);

return (
<div>
<button onClick={animateSequence}>Load Items</button>
<div ref={item1Ref} style={{ opacity: 0 }}>Item 1</div>
<div ref={item2Ref} style={{ opacity: 0 }}>Item 2</div>
<div ref={item3Ref} style={{ opacity: 0 }}>Item 3</div>
</div>
);
}

The usePrefersReducedMotion Hook

Detect user motion preference (from article 8):

// usePrefersReducedMotion.js
import { useEffect, useState } from 'react';

function usePrefersReducedMotion() {
const [prefersReduced, setPrefersReduced] = useState(false);

useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReduced(mediaQuery.matches);

const handleChange = (e) => {
setPrefersReduced(e.matches);
};

mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);

return prefersReduced;
}

export default usePrefersReducedMotion;

Combine it with other hooks:

import useAnimation from './useAnimation';
import usePrefersReducedMotion from './usePrefersReducedMotion';

export default function AccessibleAnimation() {
const { elementRef, animate } = useAnimation(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 300 }
);
const prefersReducedMotion = usePrefersReducedMotion();

const handleClick = async () => {
if (prefersReducedMotion) {
elementRef.current.style.opacity = '1'; // Jump to final state
} else {
await animate();
}
};

return (
<button ref={elementRef} onClick={handleClick} style={{ opacity: 0 }}>
Click
</button>
);
}

Combining Hooks for Complex Animations

Build complex animations by composing hooks:

import { useRef } from 'react';
import useAnimation from './useAnimation';
import useTransition from './useTransition';
import usePrefersReducedMotion from './usePrefersReducedMotion';

export default function ComplexModal() {
const [isOpen, setIsOpen] = useState(false);
const { elementRef: modalRef, isAnimating } = useTransition(
isOpen,
[{ opacity: 0, transform: 'scale(0.9)' }, { opacity: 1, transform: 'scale(1)' }],
[{ opacity: 1, transform: 'scale(1)' }, { opacity: 0, transform: 'scale(0.9)' }],
{ duration: 300, easing: 'ease-out', fill: 'forwards' }
);

const { elementRef: buttonRef, handlers } = useHover(
[{ backgroundColor: '#ff6b6b' }],
[{ backgroundColor: '#4ecdc4' }],
{ duration: 200, fill: 'forwards' }
);

const prefersReducedMotion = usePrefersReducedMotion();

if (prefersReducedMotion) {
// Simplified animations for users who prefer reduced motion
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && <div ref={modalRef}>Modal content</div>}
</div>
);
}

return (
<div>
<button ref={buttonRef} {...handlers} onClick={() => setIsOpen(!isOpen)}>
Toggle
</button>
{(isOpen || isAnimating) && <div ref={modalRef}>Modal content</div>}
</div>
);
}

Best Practices for Animation Hooks

  1. Always clean up animations on unmount to avoid memory leaks.
  2. Cancel running animations before starting new ones to prevent race conditions.
  3. Respect prefers-reduced-motion in all animation hooks.
  4. Keep hooks focused and composable; build complex animations from simple hooks.
  5. Use TypeScript if available to type keyframes and options accurately.
  6. Test with DevTools throttling to simulate slower devices.

Key Takeaways

  • Custom hooks encapsulate animation logic and eliminate boilerplate from components.
  • The useAnimation hook simplifies single-element animations with automatic cleanup.
  • The useTransition hook handles enter/exit animations in response to state changes.
  • The useHover hook enables interactive animations without CSS transitions.
  • Compose hooks to build complex animation patterns while maintaining React idioms.
  • Always respect prefers-reduced-motion in animation hooks for accessibility.

Frequently Asked Questions

Should I create a custom hook for every animation or reuse generic hooks?

Use generic, composable hooks (useAnimation, useTransition, useHover) for 80% of cases. Create custom hooks only for animation patterns that repeat across many components (e.g., a useLoadingSpinner hook).

Can I use animation hooks with server-side rendering (SSR)?

Animation hooks should only run in the browser (useEffect guarantees this). If your Next.js app hydrates before animations run, the server-rendered HTML will have animation-free styles, and the client will add animations after hydration. This is acceptable but may cause visual jumpiness. Use suppressHydrationWarning if needed.

How do I test animation hooks?

Test animation hooks by checking that:

  1. The hook renders without errors
  2. Animations are triggered and cancelled correctly
  3. Cleanup functions run on unmount (check with a memory profiler)
  4. The hook respects prefers-reduced-motion

Use @testing-library/react and mock element.animate():

vi.spyOn(Element.prototype, 'animate').mockReturnValue({
finished: Promise.resolve(),
cancel: vi.fn(),
});

Can I compose animation hooks with animation libraries like Framer Motion?

Not directly. Animation libraries manage their own animation state, and mixing them with custom hooks can cause conflicts. Choose one approach: custom hooks (for full control) or a library (for convenience). Avoid mixing.

How do I handle animation performance in hooks?

Profile with DevTools (article 7). If a hook triggers jank, check that:

  1. You are animating only compositor-friendly properties
  2. You are not running heavy computations in animation callbacks
  3. You are not triggering unnecessary React re-renders during animation

Use useMemo to avoid recreating keyframes/options on every render.

Further Reading