Build an Animated List in React
Add staggered entrance animations to any list so items appear one by one with satisfying timing.
You have a list of ten items and they all just appear at once. Nobody reads them in order, nobody notices them arriving. Add a staggered entrance and suddenly the list feels like it is telling you something.
The final result
A scrollable list where each item animates in with a scale and opacity transition when it enters the viewport. Items react to hover and click, and keyboard navigation is fully supported.
Setting up
Install the dependencies:
npm install motionImport what you need:
import { useRef, useState, useEffect, useCallback } from 'react';
import { motion, useInView } from 'motion/react';Building the component
We split this into two parts: an AnimatedItem wrapper and the AnimatedList container.
The AnimatedItem
Each item uses useInView to detect when it is at least halfway visible. The animation triggers on entry and reverses on exit because once: false is set.
const AnimatedItem: React.FC<AnimatedItemProps> = ({
children,
delay = 0,
index,
onMouseEnter,
onClick,
}) => {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { amount: 0.5, once: false });
return (
<motion.div
ref={ref}
data-index={index}
onMouseEnter={onMouseEnter}
onClick={onClick}
initial={{ scale: 0.7, opacity: 0 }}
animate={inView ? { scale: 1, opacity: 1 } : { scale: 0.7, opacity: 0 }}
transition={{ duration: 0.2, delay }}
className='mb-4 cursor-pointer'
>
{children}
</motion.div>
);
};Scroll-aware gradients
The container tracks scroll position to fade in top and bottom gradients so the list never looks like it cuts off abruptly:
const handleScroll = (e: UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } =
e.target as HTMLDivElement;
setTopGradientOpacity(Math.min(scrollTop / 50, 1));
const bottomDistance = scrollHeight - (scrollTop + clientHeight);
setBottomGradientOpacity(
scrollHeight <= clientHeight ? 0 : Math.min(bottomDistance / 50, 1)
);
};Keyboard navigation
Arrow keys and Tab move through items. When using keyboard nav, the selected item scrolls into view with a bit of extra margin:
useEffect(() => {
if (!enableArrowNavigation) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault();
setKeyboardNav(true);
setSelectedIndex(prev => Math.min(prev + 1, items.length - 1));
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
e.preventDefault();
setKeyboardNav(true);
setSelectedIndex(prev => Math.max(prev - 1, 0));
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [items, selectedIndex, onItemSelect, enableArrowNavigation]);How to use it
<AnimatedList
items={['Notification 1', 'Notification 2', 'Notification 3']}
onItemSelect={(item, index) => console.log(item, index)}
showGradients={true}
enableArrowNavigation={true}
displayScrollbar={true}
initialSelectedIndex={-1}
/>| Prop | Type | Default | Description |
|------|------|---------|-------------|
| items | string[] | 15 placeholder items | The list content |
| onItemSelect | function | - | Fires on click or Enter |
| showGradients | boolean | true | Top and bottom fade overlays |
| enableArrowNavigation | boolean | true | Keyboard support |
| displayScrollbar | boolean | true | Show or hide the scrollbar |
Key takeaways
useInViewwithonce: falselets items animate both in and out as you scroll, keeping the list feeling alive at any position.- The gradient overlays are cosmetic but do real work: they communicate that the list continues without needing a scrollbar in view at all times.
- Splitting the component into
AnimatedItemandAnimatedListkeeps each piece testable in isolation.