Direction-Aware Navigation

How to build the Aave navigation menu in Radix UI.

By Kacper Szarkiewicz

As you may know I love Aave's design. Often I try to reproduce their animations and interactions. For me it's a great way to learn new techniques and improve my skills. Let's rebuild the Aave navigation menu.

The artist ought first to exercise his hand by copying drawings from the hand of a good master. - Leonardo Da Vinci

This article is split into sections that each tackle one animation or interaction technique. Before diving in, let's go through the starting structure.

Starting code

When I saw the slick transition between menus, I knew Radix is the way to go. Especially since they feature that exact animation right in their documentation as a showcase example.

Radix's NavigationMenu is a compound component - a group of components that all work together to perform a task.

The anatomy goes like this:

  1. NavigationMenu.Root holds the state.
  2. Inside it, NavigationMenu.List is the <ul>.
  3. Each NavigationMenu.Item wraps a NavigationMenu.Trigger (the button you hover) and a NavigationMenu.Content (the dropdown panel).
  4. NavigationMenu.Indicator renders inside the list and tracks the active trigger - useful for the animated arrow below the nav bar.
  5. 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 is slotted into the shared viewport. Radix measures the active panel and exposes its dimensions as CSS variables:

  • --radix-navigation-menu-viewport-width
  • --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.

Width animation

The first thing to get right is the viewport smoothly resizing as you switch between menus 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. Apply those variables to width and height and transition the properties - the resize animates automatically. The animation section in Radix's docs covers the exact approach.

Enter and exit animations

The viewport scaling and fading as the menu opens and closes.

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.

@keyframes scaleIn {
  from { opacity: 0; scale: 0.96 }
  to   { opacity: 1; scale: 1; }
}

@keyframes scaleOut {
  to { opacity: 0; scale: 0.96; }
}

.nav-viewport{transform-origin: top center;}
.nav-viewport[data-state="open"]{animation: scaleIn  200ms ease;}
.nav-viewport[data-state="closed"]{animation: scaleOut 200ms ease;}

Transitioning between menus

Direction-aware sliding between the Markets and Developers panels.

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 from the top of the article 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 left
  • from-end - entering from the right
  • to-start - exiting to the left
  • to-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); }
}

@keyframes exitToRight {
  from { opacity: 1; transform: translateX(0); }
  to   { opacity: 0; transform: translateX(200px); }
}

@keyframes exitToLeft {
  from { opacity: 1; transform: translateX(0); }
  to   { opacity: 0; transform: translateX(-200px); }
}

.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. Radix observes which item becomes active and sets the attribute - we just name the animations.

One small addition worth making: add will-change: transform, opacity to .nav-content. These are exactly the two properties the keyframe animations touch, and the browser can promote the element to its own compositing layer before the animation starts - avoiding a first-frame stutter. The viewport gets the same treatment with will-change: transform, width, height for its resize transition. Use it targeted like this; applying will-change broadly wastes GPU memory without benefit.

If you want to learn more about will-change, check out this article by Jakub Krehel.

The sliding highlight

The gray background sliding from row to row as you hover.

Inside each panel, hovering a row moves a gray background to sit behind it. The background 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 background 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.

Icon 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 painted shapes. Swapping the parent's variables is enough to recolor the entire icon - no re-render of the SVG needed. A .nav-icon-color { transition: fill 150ms ease } rule then eases any shape tagged with that class instead of snapping.

The animated panel

The preview panel: background color animating and the icon swapping with each hovered row.

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.div animates its backgroundColor to the hovered item's color using a spring transition. Each item has a squareColor field 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. The key set to item.id tells 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.

The animated arrow

The arrow indicator sliding to track whichever trigger is open.

Below the nav bar sits a small white arrow that slides left and right to point at whichever trigger is open.

Radix already solves this with NavigationMenu.Indicator. It renders inside the list, measures the active trigger, and sets transform: translateX(...) and width on itself to stay aligned - no refs, no DOM measurement, no effects needed from us. Dropping the SVG inside it is all it takes:

<NavigationMenu.Indicator className="nav-indicator">
  <svg width="28" height="8">{...}</svg>
</NavigationMenu.Indicator>

The SVG is 28px wide and centered inside the indicator. Because Radix sets the indicator's width to match the active trigger, the SVG center naturally aligns with the trigger center - no manual offset math required.

Radix also sets data-state="visible" when a trigger is open and data-state="hidden" when none is. That's the fade handled entirely in CSS alongside the position and width transitions:

.nav-indicator {
  position: absolute;
  top: calc(100% + 2px);
  transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1),
             width   0.25s ease,
             opacity 0.2s  ease-out;
}

.nav-indicator[data-state="hidden"]  { opacity: 0; }
.nav-indicator[data-state="visible"]{ opacity: 1; }

The cubic-bezier(0.16, 1, 0.3, 1) on transform is an ease-out-expo curve that gives the slide a spring-like feel. The list itself needs position: relative so the absolutely-positioned indicator is contained within it.

Hit area

Each trigger's hit area extended via a ::before pseudo-element (debug overlay shown in red).

There are a few pixels of gap between each nav trigger. Move your cursor slowly from one item to the next and the hover state flickers off in that gap - animations reset, the arrow stutters. The fix is a ::before pseudo-element that silently fills the gap without changing the visual size of the button.

For a horizontal nav bar the hit area extends left and right. Each trigger gets relative positioning so the absolutely-positioned pseudo-element is contained, then the extension is dialed in with negative inset values:

before:absolute  // pulls it out of normal flow
before:inset-y-0  // full height of the trigger
before:inset-x-[-8px]  // 8px extension on each side
before:content-['']  // required to render the pseudo-element

The pseudo-element is invisible but pointer-active - hovers and clicks in the extended area register on the trigger itself, which is exactly what keeps the pointer considered "inside" while crossing the gap. Combined with the spring animations throughout this nav, the result is a menu that never flickers no matter how quickly you move between items.

And that's the whole rebuild. Radix handles state, ARIA, and the slot mechanics; we layered keyframes, data-motion selectors, Motion's shared layout, CSS variables for color, and a pseudo-element to smooth the gaps. If you want to dig deeper into the hit-area technique on its own, read the dedicated hit area article.

Newsletter

Stay updated with my latest articles and projects. No spam, no nonsense.