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
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
<!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.
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
- Hover & Focus State Design — the parent guide.
- Accessible CSS-Only Tooltips — longer open delays for supplementary content.
- Pointer and Hover Media Queries — when hover is not available at all.
- Responsive Navigation Without Media Queries — the navigation these menus live in.
Related articles
More pages in the same section.