# Keep Interactive Content Out of Hover Tooltips

**NEVER** · **ID:** `interactions-tooltip-interactive-content` · **Category:** interactions
**Source:** [Rauno](https://interfaces.rauno.me/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-tooltip-interactive-content

> NEVER: Put interactive content inside a hover tooltip — the tooltip unmounts as the pointer leaves the trigger and its controls never enter the tab order. If it has something to press, make it a click-triggered popover/dialog with `aria-haspopup`, focus movement and Escape dismiss.

Never put links, buttons or inputs inside a tooltip that is triggered by hover

A hover tooltip is tied to the pointer being over its trigger: the moment the pointer travels toward the tooltip it leaves the trigger and the tooltip unmounts, so the control inside can never be clicked. It is worse for keyboard users — the tooltip is only rendered while hovered, so its button never exists in the tab order, and `role="tooltip"` content is not treated as a focus container anyway. If the content has something to press, it is a popover or a dialog: give it a click trigger, `aria-haspopup`, focus movement on open and an Escape dismiss. Otherwise inline the action next to the trigger.

## Bad — do not do this

`interactions-tooltip-interactive-content-bad`

```tsx
import { useState } from 'react';

export function TooltipInteractiveContentBad() {
  const [open, setOpen] = useState(false);
  const [clicks, setClicks] = useState(0);

  return (
    <div className="w-full max-w-sm">
      <div className="bg-card border border-border rounded-lg p-4">
        <p className="text-xs text-muted-foreground mb-8">
          Hover the badge, then try to reach the &ldquo;Upgrade&rdquo; button inside the tooltip. Try Tab, too.
        </p>

        <div className="flex justify-center py-6">
          <div className="relative">
            <span
              onMouseEnter={() => setOpen(true)}
              // The tooltip is bound to the trigger's hover only. Moving the pointer
              // toward the button leaves the trigger, so the tooltip unmounts first.
              onMouseLeave={() => setOpen(false)}
              className="inline-flex items-center px-2.5 py-1 rounded-md bg-muted text-sm text-foreground border border-border"
            >
              Free plan
            </span>

            {open && (
              // role="tooltip" content is never in the tab order, and it only exists while hovered.
              <div
                role="tooltip"
                className="absolute top-full left-1/2 -translate-x-1/2 mt-2 w-52 p-3 rounded-md bg-popover text-popover-foreground border border-border text-xs shadow-md"
              >
                <p className="mb-2">You have 2 seats left on the free plan.</p>
                <button
                  onClick={() => setClicks((c) => c + 1)}
                  className="px-2 py-1 rounded bg-primary text-primary-foreground text-xs"
                >
                  Upgrade
                </button>
              </div>
            )}
          </div>
        </div>

        <p className="text-xs text-muted-foreground mt-6">
          Times &ldquo;Upgrade&rdquo; was actually pressed: <strong className="text-foreground">{clicks}</strong>
        </p>
      </div>
      <p className="text-xs text-error mt-4">
        The button dies with the hover and is never in the tab order — keyboard users cannot reach it at all
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-tooltip-interactive-content-good`

```tsx
import { useState, useRef, useEffect } from 'react';

export function TooltipInteractiveContentGood() {
  const [hint, setHint] = useState(false);
  const [open, setOpen] = useState(false);
  const [clicks, setClicks] = useState(0);
  const panelRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);

  // Move focus into the popover when it opens, return it to the trigger on close.
  useEffect(() => {
    if (open) panelRef.current?.querySelector('button')?.focus();
  }, [open]);

  const close = () => {
    setOpen(false);
    triggerRef.current?.focus();
  };

  return (
    <div className="w-full max-w-sm">
      <div className="bg-card border border-border rounded-lg p-4">
        <p className="text-xs text-muted-foreground mb-8">
          The hover tooltip carries text only. The action lives in a click-triggered popover you can Tab into and
          close with Escape.
        </p>

        <div className="flex justify-center py-6 gap-3">
          {/* Hover tooltip: purely informational, nothing to click inside it. */}
          <span className="relative">
            <span
              onMouseEnter={() => setHint(true)}
              onMouseLeave={() => setHint(false)}
              tabIndex={0}
              onFocus={() => setHint(true)}
              onBlur={() => setHint(false)}
              aria-describedby={hint ? 'plan-hint' : undefined}
              className="inline-flex items-center px-2.5 py-1 rounded-md bg-muted text-sm text-foreground border border-border outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              Free plan
            </span>
            {hint && (
              <span
                id="plan-hint"
                role="tooltip"
                className="absolute top-full left-1/2 -translate-x-1/2 mt-2 whitespace-nowrap px-2 py-1 rounded bg-popover text-popover-foreground border border-border text-xs"
              >
                2 seats left
              </span>
            )}
          </span>

          {/* Interactive content gets a real popover: focusable, dismissible, keyboard-reachable. */}
          <span className="relative">
            <button
              ref={triggerRef}
              aria-haspopup="dialog"
              aria-expanded={open}
              onClick={() => setOpen((o) => !o)}
              className="px-2.5 py-1 rounded-md border border-border bg-card text-sm text-foreground outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              Details
            </button>
            {open && (
              <div
                ref={panelRef}
                role="dialog"
                aria-label="Plan details"
                onKeyDown={(e) => e.key === 'Escape' && close()}
                className="absolute top-full right-0 mt-2 w-52 p-3 rounded-md bg-popover text-popover-foreground border border-border text-xs shadow-md z-10"
              >
                <p className="mb-2">You have 2 seats left on the free plan.</p>
                <div className="flex gap-2">
                  <button
                    onClick={() => {
                      setClicks((c) => c + 1);
                      close();
                    }}
                    className="px-2 py-1 rounded bg-primary text-primary-foreground text-xs outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
                  >
                    Upgrade
                  </button>
                  <button
                    onClick={close}
                    className="px-2 py-1 rounded bg-muted text-foreground text-xs outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
                  >
                    Close
                  </button>
                </div>
              </div>
            )}
          </span>
        </div>

        <p className="text-xs text-muted-foreground mt-6">
          Times &ldquo;Upgrade&rdquo; was actually pressed: <strong className="text-foreground">{clicks}</strong>
        </p>
      </div>
      <p className="text-xs text-success mt-4">
        The popover stays open while you move to it, is reachable by Tab, and closes on Escape
      </p>
    </div>
  );
}
```

## References

- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [WAI-ARIA APG: Tooltip pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/)
