Hover Intent Delays With transition-delay: Menus That Don't Flicker

Move a pointer across a navigation bar with hover-triggered dropdowns and every menu on the path flashes open and shut. Move it diagonally from a top-level item toward the third entry of its submenu and the path clips a neighbouring item, whose menu instantly replaces the one you were heading for. Both problems come from reacting to hover too literally: the browser reports the pointer's position every frame, and a menu that opens and closes on every report behaves like a nervous machine rather than a considerate interface. The narrow fix this page teaches is hover intent in pure CSS — short, asymmetric delays built from transition-delay that ignore passing pointers, tolerate imprecise movement, and never slow down keyboard users. It belongs to Hover & Focus State Design in the CSS-Only Micro-Interactions & Animations guide.

Why reacting instantly to hover backfires

Hover is a proxy for attention, and a poor one. A pointer crossing a menu bar on its way to the page content is not attending to any menu; it just happens to be there for 40 milliseconds. Opening a dropdown for each of those passes creates visual noise and, worse, can cover the content the user was reaching for.

The opposite problem is closing too eagerly. Submenus and mega menus sit beside or below their triggers, and the natural path from trigger to target is diagonal. That diagonal often crosses another trigger or a gap where no element is hovered, and an instant close destroys the menu the user was travelling toward. Desktop operating systems solved this decades ago with small delays and "safe triangles"; the web frequently forgets.

JavaScript hover-intent libraries measure pointer speed and direction. For most menus and tooltips a simpler rule works: wait a little before opening, and wait a little longer before closing. CSS can express exactly that with delays on the right states.

The timeline of a hover with intent delays

Open delay filters passes; close delay forgives exits Top row: the pointer is over the trigger for 60 milliseconds, shorter than the 150 millisecond open delay, so the menu never appears. Bottom row: the pointer rests for 400 milliseconds, the menu opens after 150, then the pointer leaves for 180 milliseconds, which is shorter than the 300 millisecond close delay, so the menu stays open. Two pointers, same trigger passing hover 60ms open delay 150ms never completes → no menu resting hover away 180ms hover again menu open: gap shorter than close delay +150ms Delays are cancelled, not queued: leaving before the delay ends means nothing happens.

The key behaviour is that a pending transition delay is cancelled if the state changes back before the delay elapses. A pointer that enters and leaves within 150 milliseconds starts an open transition that never begins, and the browser reverts to the closed state with nothing drawn. A pointer that leaves an open menu for less than the close delay starts a close transition that is cancelled on return. No timers, no state machine — the transition engine provides both behaviours.

The complete implementation

Live demoNavigation menus with hover-intent delays
Sweep the pointer quickly across the items: nothing opens. Rest on one: its menu opens after 150ms and survives a brief exit.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hover intent delays</title>
<style>
  :root {
    --intent-open: 150ms;     /* ignore pointers passing through */
    --intent-close: 300ms;    /* forgive diagonal trips to the menu */
  }

  body { font: 15px/1.5 system-ui, sans-serif; margin: 1.5rem; }

  .nav { display: flex; gap: 0.25rem; margin: 0; padding: 0; list-style: none; }
  .nav > li { position: relative; }
  .nav > li > a {
    display: block;
    padding: 0.5rem 0.9rem;
    border-radius: 6px;
    color: inherit;
    text-decoration: none;
  }

  .submenu {
    position: absolute;
    top: 100%;
    left: 0;
    min-width: 12rem;
    margin: 0;
    padding: 0.4rem;
    list-style: none;
    border: 1px solid #cbd5e1;
    border-radius: 10px;
    background: #ffffff;

    /* CLOSED. This transition runs when leaving the open state, so its
       delay is the close delay. visibility waits for the fade to finish. */
    opacity: 0;
    visibility: hidden;
    translate: 0 -0.25rem;
    transition:
      opacity 120ms ease-in var(--intent-close),
      translate 120ms ease-in var(--intent-close),
      visibility 0s linear calc(var(--intent-close) + 120ms);
  }

  /* OPEN via hover: its transition runs when entering, so its delay is
     the open delay. */
  .nav > li:hover > .submenu {
    opacity: 1;
    visibility: visible;
    translate: 0 0;
    transition:
      opacity 160ms ease-out var(--intent-open),
      translate 160ms ease-out var(--intent-open),
      visibility 0s linear var(--intent-open);
  }

  /* OPEN via keyboard: no intent delay, focus is always deliberate. */
  .nav > li:focus-within > .submenu {
    opacity: 1;
    visibility: visible;
    translate: 0 0;
    transition: opacity 160ms ease-out, translate 160ms ease-out, visibility 0s;
  }

  .submenu a { display: block; padding: 0.4rem 0.6rem; border-radius: 6px; color: inherit; text-decoration: none; }
  .submenu a:hover, .submenu a:focus-visible, .nav > li > a:hover { background: #eef2ff; }
  a:focus-visible { outline: 2px solid #4f46e5; outline-offset: 2px; }

  @media (prefers-reduced-motion: reduce) {
    .submenu, .nav > li:hover > .submenu { translate: 0 0; }
  }
</style>
</head>
<body>
  <ul class="nav">
    <li><a href="/products/">Products</a>
      <ul class="submenu"><li><a href="/products/api/">API</a></li><li><a href="/products/cli/">CLI</a></li></ul></li>
    <li><a href="/docs/">Docs</a>
      <ul class="submenu"><li><a href="/docs/start/">Getting started</a></li><li><a href="/docs/ref/">Reference</a></li></ul></li>
    <li><a href="/pricing/">Pricing</a></li>
  </ul>
</body>
</html>

The visibility delays are the subtle part. When closing, visibility must stay visible until the close delay and the fade are both over, so its delay is their sum. When opening via hover, visibility must switch at the same moment the fade starts, so its delay equals the open delay. When opening via focus, everything is immediate.

The key technique: the entered state owns the delay

The asymmetry works because of one cascade rule: when an element's state changes, the transition values of the new state apply. The :hover rule's delay therefore governs opening, and the base rule's delay governs closing. The same rule enables entry versus exit easing; here it is used for timing rather than curves.

Which delay applies when Moving into the hover state uses the hover rule's 150 millisecond delay. Moving into the focus state uses zero delay. Moving back into the closed state uses the base rule's 300 millisecond delay. Each state carries the delay for arriving in it closed delay 300ms :hover open delay 150ms :focus-within open delay 0 pointer rests Tab arrives

Because both open rules set the same properties, whichever matches applies; if a keyboard user focuses a link inside a menu that is also hovered, both selectors match and the later rule in source order — the focus rule — wins, giving immediate response. That ordering is deliberate.

Choosing the numbers

The open delay should be long enough to filter passing pointers and short enough that a deliberate hover feels responsive. Studies of desktop menus and long practice converge on roughly 100 to 200 milliseconds; below 80 many passes still trigger, above 250 the menu feels sluggish. The close delay should cover a diagonal trip across a gap or a neighbouring trigger, which for typical menu geometry is 200 to 400 milliseconds. Tooltips are the exception: they benefit from a longer open delay — 300 to 500 milliseconds — because they are supplementary, and a very short close delay, because a lingering tooltip obscures content.

Geometry changes the numbers too. A submenu that opens directly beneath its trigger with no gap needs very little close delay, because the pointer never leaves a hovered element on the way down. A mega menu that opens a large panel offset from its trigger, or a fly-out that opens to the side across a gap, needs the upper end of the range. Closing the gap is often better than lengthening the delay: extend the trigger's hit area with padding, or position the submenu so it overlaps the trigger's edge by a few pixels, and the pointer stays inside a hovered element throughout the trip.

Put the values in tokens, as the example does, so every hover-triggered component in the product shares the same intent timing. Inconsistent delays between menus are more disorienting than any particular value.

Accessibility notes

Hover-only interactions exclude keyboard, touch and many assistive-technology users, so every hover menu must also open on focus — here via :focus-within — and on touch devices should be reachable by tapping the parent link or a disclosure button. WCAG 1.4.13 Content on Hover or Focus requires that content revealed on hover can be dismissed without moving the pointer (usually Escape, which needs a few lines of script or the popover API), that the pointer can move onto the revealed content without it disappearing (which the close delay and adjacent positioning provide), and that it remains visible until dismissed or no longer relevant. The close delay helps with the second requirement directly. For devices without hover at all, gating hover behaviour with pointer and hover media queries avoids sticky-hover problems on touchscreens.

Browser support

transition-delay and per-state transitions are supported in every browser with CSS transitions, which dates from Chrome 26, Edge 12, Firefox 16 and Safari 9. :focus-within is supported in Chrome 60+, Edge 79+, Firefox 52+ and Safari 10.1+. Custom properties inside transition delays and calc() in time values are supported in all current engines. The standalone translate property is supported in Chrome and Edge 104+, Firefox 72+ and Safari 14.1+.

FAQ

What is hover intent? The idea that a pointer passing briefly over an element is not the same as a user wanting to interact with it. Hover-intent techniques wait a short time before reacting to hover, so menus and tooltips only open when the pointer actually rests on their trigger.

How can CSS delay a hover effect without JavaScript? Put a transition-delay on the hovered state. Because the transition values of the state being entered apply, the delay only affects entering hover. Leaving hover uses the base state's transition, which can have a different or zero delay.

Why should the close delay differ from the open delay? Opening should wait long enough to ignore passing pointers, typically 100 to 200 milliseconds. Closing should wait a little so a user moving diagonally from a trigger into its submenu does not lose it on the way, typically 200 to 400 milliseconds.

Should keyboard focus be delayed like hover? No. Focus is always intentional, so focus-triggered menus and tooltips should appear immediately. Use separate selectors so :focus-visible and :focus-within states have no delay while :hover keeps its intent delay.

Related articles

More pages in the same section.