October 27, 2025

React Hover State Gotchas

How a Delete Dialog Broke My Sidebar Animation

If you've spent any serious time building React UIs, you've probably run into a moment where the interface does something subtly wrong something that works 95% of the time, but breaks in a specific sequence of actions that a real user will absolutely discover. This is the story of one of those bugs, why it happens, and what the proper mental model is for hover state in React.

Let me set the scene.....

So the setup

I had a sidebar with a list of notes. Each item had a hover animation: when you hovered over a note, a settings buttons would slide in ,from the rightAlertDialog (from ui.shadcn.com) asking the user to confirm. Standard stuff.

Here's the sequence :

  1. Hover over a note animation plays, settings buttons appears.

  2. Click the settings button, Delete.

  3. The confirmation dialog opens.

  4. Click Cancel.

  5. The dialog closes. But the hover animation is now frozen the slider buttons is still visible, even though the mouse isn't anywhere near the item.

The only way to clear it was to hover over the item again and then move away. It looked broken. Because it is lmao.


Why This Happens: The Real Story of Mouse Events

To understand the bug, you need to understand something about how the browser fires mouse events when an element is removed from or covered by the DOM.

When you open a modal or dialog, it typically renders into a portal a DOM node that exists outside your component tree, usually appended to document.body. Radix dialogs do exactly this. The dialog backdrop covers the sidebar item, but importantly, the browser does not fire a mouseleave event on the sidebar item when this happens.

Why not? Because from the browser's perspective, your mouse didn't leave the element an overlay appeared on top of it. The pointer is still at the same coordinates. The element is still technically under the cursor. No mouseleave. No state update. :(

So isHovered stays true.

Now when you click Cancel, the dialog is removed from the DOM. The browser sees the pointer is now over the sidebar item again (nothing changed from the pointer's perspective).

It might fire a mouseenter, but it definitely doesn't fire mouseleave. So there's no trigger to set isHovered back to false.

The component is stuck. It thinks it's being hovered. The animation stays in its "hovered" state indefinitely.

This isn't a React bug or a shadcn bug. It's the browser behaving correctly and our component not accounting for it.


The Gotcha Spelled Out

The core mistake is treating isHovered as a reliable source of truth when it's actually derived from browser events that can be missed.

Here's the faulty mental model:

"If the mouse hasn't left the element, isHovered is true. If the mouse has left the element, isHovered is false."

And here's the real world:

"If the mouse hasn't left the element, OR if a portal rendered overlay appeared on top of it without triggering mouseleave, isHovered might be true."

That's a subtle but important difference. Browser pointer events are not a perfect bidirectional contract. You can lose events. You can miss transitions. Anything that teleports the mouse from one DOM context to another dialogs, tooltips, drag events, focus traps can produce gaps in your event stream.


The Fix: Coordinate State With the Dialog

The correct approach is to make the hover state aware of when a child dialog is open. When the dialog is open, you suppress the mouseleave handler so it can't reset hover.

When the dialog closes, you explicitly reset hover yourself.

Here's what that looks like in practice.

In your sidebar item component, add a second piece of state:

const [isHovered, setIsHovered] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);

Update your mouseleave handler to check it:

const handleContentMouseLeave = useCallback(() => {
  if (!isDialogOpen) {
    setIsHovered(false);
  }
}, [isDialogOpen]);

Pass a callback into your settings component so it can notify the parent when the dialog opens and closes:

<NoteSettingsSidbar
  noteId={note._id}
  noteTitle={note.title}
  onDialogOpenChange={(open) => {
    setIsDialogOpen(open);
    if (!open) setIsHovered(false); // explicitly reset on close
  }}
/>

And in your NoteSettingsSidbar, wire the callback to your AlertDialog's onOpenChange:

<AlertDialog
  open={deleteDialogOpen}
  onOpenChange={(val) => {
    setDeleteDialogOpen(val);
    onDialogOpenChange?.(val);
  }}
>

That's the whole fix. When the dialog opens, the parent knows about it and freezes the hover state.

When Cancel is clicked, onOpenChange fires with false, the parent resets isHovered, and the animation snaps back to its correct state no re-hover required.


Broader Lessons: Hover State Is Fragile

This bug is a representative example of a whole class of problems. Here are a few related hover state gotchas worth keeping in mind.

1. Tooltips Can Cause The Same Issue

If you're using TooltipProvider with disableHoverableContent set to false, the tooltip element itself is a hoverable DOM node. Moving the mouse from your trigger into the tooltip content won't fire mouseleave on the trigger Radix deliberately bridges that gap. This is usually what you want, but it can interfere with your own hover tracking if both are active on the same element.

2. Drag-and-Drop Breaks Hover Completely

During a drag operation, the browser can stop firing normal pointer events on elements underneath the drag. If you have hover state tied to onMouseEnter and onMouseLeave, dragging items around your UI can leave elements in a permanently hovered state. The fix here is typically to reset all hover state when a drag starts, using a global drag event listener.

3. CSS :hover vs. React State Hover

In many cases, pure CSS hover (:hover pseudo-class) is more reliable than JavaScript hover state, because the browser manages it directly and doesn't rely on event delivery. If your hover effect is purely visual and doesn't need JavaScript logic, consider keeping it in CSS only. You only need JS hover state when the hover drives conditional rendering, data fetching, or state changes that CSS can't express.

4. Pointer Events on Absolutely Positioned Children

When you have an absolutely positioned element inside a hover zone (like a settings button sliding in from the right), moving the mouse from the container into the absolute child doesn't fire mouseleave on the container they're still in the same event bubble. But moving outside both of them does. Make sure your onMouseLeave is on the outermost container, not the inner content, otherwise you'll get flickering as the cursor crosses between them.

5. Touch Devices Don't Have Hover

This one is obvious but easy to forget: onMouseEnter and onMouseLeave don't fire reliably on touch devices. If your UI has meaningful hover-triggered states, either ensure there's a tap equivalent, or use the CSS media query @media (hover: hover) to limit hover styles to devices that actually support it. Don't hide important actions behind hover on touch screens.


So What's the Real Lesson Here?

The bug we walked through wasn't caused by bad code. The component logic was clean, the animations were well-implemented, and the event handlers were correct for the common case. The problem was an edge case where two independent pieces of the UI the hover animation and the dialog lifecycle needed to be coordinated but weren't.

That's the real lesson. In complex UIs, hover state isn't just about the mouse. It's about the relationship between your component and everything else that can change the visual and interaction context around it overlays, portals, focus traps, drag operations. Hover state that ignores those relationships will eventually surface a bug that makes your interface feel unreliable.

The fix is always the same: make the relevant pieces of state aware of each other. Keep the coordination explicit. And when something looks "stuck," ask not just "what event fired?" but "what event was supposed to fire and didn't?"

That question will save you hours.