← ./articles

React Tooltip Appears on Mouse Click? Check :focus-visible

You replace an HTML title attribute with a custom React tooltip. It works with Tab, but it also appears after a mouse click because both keyboard navigation and pointer clicks can focus the trigger.

Do not remove focus support. Distinguish keyboard-style focus with :focus-visible, while keeping hover behavior separate.

Why onFocus alone cannot identify keyboard navigation

React's onFocus tells you that focus entered an element. It does not tell you which input device caused it. A button normally receives focus after a mouse click, so this code produces false positives:

<button onFocus={() => setVisible(true)}>Open</button>

Global “last input was keyboard” flags are possible, but they add document listeners and can drift across windows or embedded WebViews. The browser's :focus-visible heuristic is a better default for deciding whether to show a visual focus-triggered tooltip.

Check the actual trigger with :focus-visible

Keep the ref on the focusable trigger, not on a non-focusable wrapper:

const triggerRef = useRef<HTMLButtonElement>(null);
const [hovered, setHovered] = useState(false);
const [keyboardFocused, setKeyboardFocused] = useState(false);

const visible = hovered || keyboardFocused;

<button
  ref={triggerRef}
  aria-describedby={visible ? tooltipId : undefined}
  onFocus={(event) => {
    setKeyboardFocused(event.currentTarget.matches(":focus-visible"));
  }}
  onBlur={() => setKeyboardFocused(false)}
  onMouseEnter={() => setHovered(true)}
  onMouseLeave={() => setHovered(false)}
>
  Open
</button>

Use currentTarget: it is the element that owns the handler. target may be a nested icon or span.

Keep hover and focus state independent

A single Boolean can hide the tooltip too early. For example, mouseleave might set it to false while keyboard focus still requires it. Separate state makes the rule explicit:

const visible = hovered || keyboardFocused;

Render a stable tooltip ID so the trigger can reference it:

{visible && (
  <div id={tooltipId} role="tooltip">
    Open a project
  </div>
)}

The tooltip itself should not receive focus or contain buttons and links. Use a non-modal dialog or popover for interactive content.

Dismiss with Escape without discarding focus

WAI guidance expects Escape to dismiss an open tooltip. Keep focus on the trigger and suppress the tooltip until focus changes:

onKeyDown={(event) => {
  if (event.key === "Escape") {
    setKeyboardFocused(false);
  }
}}

Do not call blur() merely to hide the tooltip. Removing focus can disrupt keyboard navigation. If CSS :focus-visible would immediately reopen it on the next render, track an escapeDismissed flag and reset that flag on blur.

Verify every input path

Check these behaviors independently:

  1. Mouse hover shows the tooltip.
  2. Mouse leave hides it when keyboard focus is absent.
  3. Mouse click does not show it solely because focus changed.
  4. Tab focus shows it.
  5. Escape hides it while focus stays on the trigger.
  6. aria-describedby references the visible tooltip ID.
  7. Moving the pointer over the tooltip does not make required content impossible to read.

The last point is part of WCAG's hover-or-focus guidance. If the content is important, visible text may be better than a tooltip.

References

Summary

onFocus includes mouse-created focus. Check event.currentTarget.matches(':focus-visible'), keep hover and keyboard state separate, connect the tooltip with aria-describedby, and dismiss it with Escape without moving focus.