Navigation animation built using React refs, state management, and CSS transforms.
Before diving into how this component is built, I think it's important to understand what makes a good navigation animation. The sliding background needs to feel responsive and natural, not robotic. I also made this component so feel free to interact with it below!
A good navigation transition animates the background position smoothly. But if you change the animation speed (button in the top-right corner), you'll notice how the easing function affects the feel of the transition at different speeds.
Click the speed button (1x, 2x, 3x) in the top-right corner of the navigation demo above to see how different animation speeds affect the feel of the transition. Notice how the easing function creates different bounce effects at various speeds.
You can think of refs as a way to directly access DOM elements without causing re-renders. This is crucial for performance when we need to measure element positions frequently.
const navBackgroundRef = useRef<HTMLDivElement>(null);
const navItemsRef = useRef<{[key: string]: HTMLButtonElement | null}>({});
// Access the element
const background = navBackgroundRef.current;
We store references to both the background element and all navigation items. This lets us measure their positions without triggering React re-renders every time we need to calculate dimensions.
To move the background smoothly, we need to know exactly where each navigation item is positioned. The getBoundingClientRect()
method gives us precise measurements relative to the viewport.
const rect = item.getBoundingClientRect();
const containerRect = item.closest('.nav-container')?.getBoundingClientRect();
// Calculate relative position
const left = rect.left - containerRect.left;
const width = rect.width;
The calculation here is straightforward but crucial. We get the position of both the navigation item and its container, then subtract to find the relative position. This ensures the background moves to the correct spot regardless of where the navigation is positioned on the page.
The animation uses cubic-bezier(0.34, 1.2, 0.64, 1)
which creates a subtle bounce effect. The second value (1.2) exceeds 1.0, causing the animation to slightly overshoot its target before settling back.
background.style.transitionTimingFunction = "cubic-bezier(0.34, 1.2, 0.64, 1)";
Without this overshoot, the animation would feel mechanical and lifeless. The slight bounce makes it feel more natural and playful, like a physical object with momentum. Try adjusting the speed to see how the easing affects the feel at different rates.
We track two separate states: which item is active (clicked) and which item is being hovered. This creates an intuitive preview effect where users can see where they'll go before clicking.
const [activeNavItem, setActiveNavItem] = useState("playground");
const [hoveredNavItem, setHoveredNavItem] = useState<string | null>(null);
// On hover, show preview
onMouseEnter={() => {
setHoveredNavItem(item.id);
updateNavBackground(item.id);
}}
// On leave, return to active
onMouseLeave={() => {
setHoveredNavItem(null);
updateNavBackground(activeNavItem);
}}
When you hover over an item, the background moves to show a preview. When you move your mouse away, it returns to the active item. This gives users immediate visual feedback about where they are and where they could go.
The speed control in the top-right corner lets you slow down or speed up the animation. This is incredibly useful for debugging and fine-tuning the easing curve.
background.style.transitionDuration = `${400 / animationSpeed}ms`;
At 1x speed, the animation takes 400ms. At 2x, it takes 200ms. Playing animations in slow motion often reveals timing issues that are hard to spot at normal speed. It's a technique I use constantly when building interactions.
On smaller screens, the text labels hide while icons remain visible. The background animation adapts automatically because it calculates dimensions dynamically.
<span className="hidden sm:inline">
{item.label}
</span>
Since we measure the actual rendered size of each button, the animation works perfectly whether the text is visible or hidden. No special mobile logic needed—it just works.
When the component first mounts, we need to position the background on the active item. We use a small delay to ensure the DOM has fully rendered before measuring.
useEffect(() => {
if (activeNavItem) {
setTimeout(() => updateNavBackground(activeNavItem), 100);
}
}, [activeNavItem]);
The 100ms delay gives React time to render all the navigation items before we try to measure them. Without this, getBoundingClientRect()
might return incorrect values because the elements aren't fully laid out yet.