Direction-Aware Navigation
An Aave-style dropdown navigation built with Radix UI - direction-aware slide animations in a few lines of CSS.
Let's build an Aave-style navigation menu. This article is split into sections that each tackle one animation or interaction technique. Before diving in, let's go through the starting structure.
01Starting code
Radix's NavigationMenu is a compound component - a group of smaller pieces that work together. The anatomy goes like this: NavigationMenu.Root holds the state. Inside it, NavigationMenu.List is the <ul>. Each NavigationMenu.Item wraps a NavigationMenu.Trigger (the button you hover) and a NavigationMenu.Content (the dropdown panel). At the bottom, NavigationMenu.Viewport is the single container that all panels animate into - only one panel is ever visible at a time.
The viewport pattern is what makes width and height transitions possible. Instead of each panel rendering in its own container, all content portals into the shared viewport. Radix measures the active panel and exposes its dimensions as CSS variables - --radix-navigation-menu-viewport-width and --radix-navigation-menu-viewport-height - which we can then transition in CSS.
Keyboard navigation, ARIA roles, focus management, and the small open delay that prevents accidental triggers on fast cursor passes - all handled. You can read the Radix API reference to understand each primitive in detail.
02Width animation
The first thing to get right is the viewport smoothly resizing as you switch between panels of different widths and heights. Without this, the dropdown snaps between sizes - which looks broken at any animation speed.
Radix exposes the active panel's dimensions via CSS variables on the viewport element. Transition those variables in CSS and the resize animates automatically. The animation section in Radix's docs covers the exact approach.
03Enter and exit animations
Next, animate the viewport opening and closing. When you hover a trigger the menu should scale and fade in; when you leave it should scale and fade out. This goes on the .nav-viewport element using data-state="open" and data-state="closed" attribute selectors that Radix sets automatically.
04Switching between menus
Now for the main event. When you hover a trigger while the menu is already open, the old panel should exit to one side while the new panel enters from the other - the direction depending on which way you moved. Hover the demo above and move left and right to see it.
Radix does the hard part: it sets a data-motion attribute on NavigationMenu.Content that describes exactly what's happening:
from-start- entering from the leftfrom-end- entering from the rightto-start- exiting to the leftto-end- exiting to the right
All we need is four keyframes and four attribute selectors to assign them. Fixed translateX values keep the displacement consistent regardless of panel width.
@keyframes enterFromRight {
from { opacity: 0; transform: translateX(200px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes enterFromLeft {
from { opacity: 0; transform: translateX(-200px); }
to { opacity: 1; transform: translateX(0); }
}
.nav-content[data-motion="from-start"] { animation-name: enterFromLeft; }
.nav-content[data-motion="from-end"] { animation-name: enterFromRight; }
.nav-content[data-motion="to-start"] { animation-name: exitToLeft; }
.nav-content[data-motion="to-end"] { animation-name: exitToRight; }That's it for the direction-aware part. No JavaScript tracking the previous trigger. Radix observes which item becomes active and sets the attribute - we just name the animations.
05The sliding highlight
Inside each panel, hovering a row moves a gray pill to sit behind it. The pill doesn't snap - it slides from row to row. This is Motion's shared layout animation. Give two renders of the same element the same layoutId and Motion interpolates between their positions automatically, without you doing any position math.
Each list tracks a hoveredId state. When it changes, the pill conditionally renders at the new item - both the old and new instance share the same layoutId, so Motion treats them as one element moving rather than two elements replacing each other.
One detail: each panel needs its own layoutId string so the Markets and Developers highlights don't cross-animate when you switch panels. A simple convention like `${panelValue}-highlight` works.
06Icon color theming
Icons render gray by default and shift to a per-item accent color on hover. No conditional class swapping, no separate hover icon variants - the technique is CSS custom properties.
Each nav item in the data carries a cssVars object with default and hover states. The icon wrapper applies whichever set is active as inline custom properties on the wrapping <span>:
cssVars: {
default: { "--color-1": "#bcbbbb", "--color-2": "#8f8f8f" },
hover: { "--color-1": "#39D1F9", "--color-2": "#A7E9FD" }
}Each SVG icon uses var(--color-1) and var(--color-2) as fill values on its paths. Swapping the parent's variables is enough to recolor the entire icon - no re-render of the SVG needed. A transition: fill 150ms ease rule on the paths handles the fade.
07The animated panel
Each dropdown has a preview panel on the right that shows the hovered item's icon against a themed background. When nothing is hovered, it defaults to the first item - so it's never empty.
Two animations run independently inside the panel:
- Background color - a
motion.divanimates itsbackgroundColorto the hovered item's color using a spring transition. Each item has asquareColorfield that drives this. - Icon swap -
<AnimatePresence mode="wait">wraps the icon. When the hovered item changes, the old icon exits with a scale + fade before the new one enters. Thekeyset toitem.idtells React each icon is a distinct element, which is what triggers the enter/exit cycle.
The icon inside the panel always uses the hover color vars - it's a preview of the active state. And useReducedMotion gates the scale: users who prefer reduced motion get the opacity fade only, with scale locked to 1.
Newsletter
Stay updated with my latest articles and projects. No spam, no nonsense.