Command Palette

Search for a command to run...

Design Engineering
Dropdowns that respond tointent

Dropdowns that respond to intent.

How velocity-based timing makes menus feel like they're reading your mind instead of following a script.

4 min read·Design Engineering

Click a dropdown trigger. Wait 150ms. The menu fades in over 200ms. Total time from click to usable menu: 350ms.

That's the standard. Every component library ships it. And it's wrong, because it treats every click the same.

A fast, decisive click means "I know what I want, show me the menu now." A slow, exploratory click means "I'm not sure, let me see what's here." Same action, different intent. Same animation speed.


Reading velocity from clicks

Every click has measurable properties beyond just "happened":

const handleClick = (e: React.MouseEvent) => {
  // How long the mouse button was held
  const pressDuration = e.timeStamp - mouseDownTime;
 
  // Fast click: < 150ms press
  // Slow click: > 300ms press
  const isFastClick = pressDuration < 150;
};

A fast press-release (under 150ms) indicates decisiveness. The user knows this is a dropdown and wants it open. A long press (over 300ms) indicates uncertainty. They're hovering over the trigger, thinking about whether to engage.


Adaptive entrance timing

const getTransition = (isFastClick: boolean) => ({
  type: "spring",
  stiffness: isFastClick ? 700 : 400,
  damping: isFastClick ? 35 : 30,
});

Fast click = stiff spring. The menu snaps open in roughly 120ms. No perceptible delay.

Slow click = softer spring. The menu eases in over roughly 250ms. A gentler entrance that matches the exploratory mood.

The user never notices the difference consciously. They just feel like the menu "gets" them.


The hover delay problem

Hover-triggered dropdowns (navigation menus) have a classic problem: the delay. Too short, and menus flash open accidentally as the cursor crosses triggers. Too long, and deliberate hovers feel unresponsive.

Most implementations use a fixed delay: 100-200ms. This is a compromise that satisfies nobody.

Velocity-based hover intent:

const handleMouseEnter = (e: React.MouseEvent) => {
  const speed = Math.sqrt(e.movementX ** 2 + e.movementY ** 2);
 
  if (speed < 2) {
    // Slow/stationary cursor - deliberate hover
    openMenu(50); // 50ms delay
  } else if (speed > 10) {
    // Fast cursor - passing through
    openMenu(250); // 250ms delay
  } else {
    // Medium speed - standard delay
    openMenu(150);
  }
};

A cursor that decelerates over the trigger is intentional. A cursor blasting through at high speed is accidental. Adjust the delay accordingly.


Exit: the diagonal problem

The classic dropdown escape problem: the menu is below and to the right of the trigger. Moving the cursor from the trigger to a menu item requires diagonal movement that briefly leaves both the trigger and the menu. The menu closes.

CSS solutions use a "grace area," an invisible triangle between the trigger and menu that keeps the menu open during diagonal movement. This works but it's a spatial hack.

The spring-based solution: delayed exit with velocity check.

const handleMouseLeave = (e: React.MouseEvent) => {
  const movingTowardMenu = e.movementY > 0; // moving downward
 
  if (movingTowardMenu) {
    // Moving toward menu - long grace period
    closeTimer = setTimeout(closeMenu, 300);
  } else {
    // Moving away - close quickly
    closeTimer = setTimeout(closeMenu, 50);
  }
};

If the cursor leaves the trigger moving downward (toward the menu), it gets 300ms of grace. If it leaves moving upward or sideways (away from the menu), it closes in 50ms. Direction-aware, not area-aware.


Most menus highlight items with a CSS background transition:

.item:hover {
  background: rgba(0, 0, 0, 0.05);
  transition: background 0.1s;
}

Spring-based highlight with a shared layout animation:

<motion.div
  layoutId="menu-highlight"
  className="absolute inset-0 rounded bg-accent"
  transition={{ type: "spring", stiffness: 500, damping: 35 }}
/>

The highlight slides between items instead of each item independently fading its own background. The highlight is a single element that springs from one item to the next, carrying momentum.

Fast cursor movement through items = the highlight trails behind and catches up. Slow movement = the highlight follows closely. The spring creates a physical relationship between cursor speed and highlight behavior.


Keyboard navigation

Spring animations work for keyboard navigation too. Arrow-down moves the highlight to the next item with the same spring. The motion communicates "you moved one step" more clearly than an instant highlight swap.

Type-ahead (pressing "S" to jump to items starting with S) can use a stiffer spring. The jump is larger and more decisive, so the animation should match.


The best dropdown is one that feels like it understands what you're trying to do. Not because it's using AI. Because it's reading the physics of your input and responding proportionally. Speed, direction, duration. Every gesture carries information. Use it.

Read more like this

Dropdowns that respond to intent. | Ruixen UI | Ruixen UI