# Make Hover Content Dismissible, Hoverable and Persistent

**MUST** · **ID:** `interactions-hover-content-persistence` · **Category:** interactions
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-hover-content-persistence

> MUST: Hover/focus popups must be DISMISSIBLE (Escape hides it without moving pointer or focus), HOVERABLE (the pointer can travel into the content and stay there — bridge the trigger/card gap with wrapper padding, not a margin) and PERSISTENT (no auto-hide timer; it stays until the trigger is left, the user dismisses it, or the info goes stale). WCAG SC 1.4.13 Content on Hover or Focus, Level AA.

Let Escape close a hover card, let the pointer enter it, and never expire it on a timer

The missing sibling of the two tooltip rules already here. "Tooltip Timing" governs the show-delay; "No Interactive Content in Tooltips" governs what may go inside. Neither says what happens once the thing is on screen — and that is where three separate, independently-failing requirements live. DISMISSIBLE: Escape must hide it without moving the pointer or focus, because a screen-magnifier user viewing a small slice of the page may have a popup covering the content they were reading, and forcing them to move the pointer costs them their place. HOVERABLE: the pointer must be able to travel into the content and stay there, which is why the classic 2px "breathing gap" between trigger and card is a bug, not a detail — crossing it fires mouseleave and the card evaporates. Bridge it with padding on the wrapper (an invisible safe area) rather than a margin on the card, or hit-test a safe triangle toward the card. PERSISTENT: no auto-hide timers. A 3-second dismissal is an eternity for a magnifier user panning across the card and no time at all for someone reading with a screen reader; the content stays until the trigger is left, the user dismisses it, or the information stops being true.

## Bad — do not do this

`interactions-hover-content-persistence-bad`

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

export function HoverContentPersistenceBad() {
  const [open, setOpen] = useState(false);
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => () => clearTimeout(timerRef.current), []);

  return (
    <div className="w-full max-w-sm">
      <div className="rounded-lg border border-border bg-card p-4">
        <p className="text-sm text-foreground">
          Assigned to{' '}
          <span className="relative inline-block">
            <button
              type="button"
              onMouseEnter={() => {
                setOpen(true);
                // PERSISTENT violated: the card evaporates on a timer, whether or
                // not the user is done reading it.
                clearTimeout(timerRef.current);
                timerRef.current = setTimeout(() => setOpen(false), 3000);
              }}
              // HOVERABLE violated: leaving the trigger closes the card, and there
              // is a 8px gap of dead space to cross to reach it.
              onMouseLeave={() => setOpen(false)}
              className="font-medium text-primary underline underline-offset-2"
            >
              @dana
            </button>

            {open && (
              <span className="absolute left-0 top-full z-10 mt-2 block w-56 rounded-lg border border-border bg-popover p-3 text-xs text-popover-foreground shadow-md">
                <span className="block font-medium">Dana Okafor</span>
                <span className="mt-1 block text-muted-foreground">
                  Staff engineer · Platform
                </span>
                <span className="mt-2 block text-primary underline">
                  View profile
                </span>
              </span>
            )}
          </span>{' '}
          for review.
        </p>
      </div>

      <p className="mt-3 text-xs text-muted-foreground">
        Hover <em>@dana</em>. Now try to move your pointer into the card to read it or
        click <em>View profile</em> — crossing the gap fires <code>mouseleave</code>{' '}
        and the card is gone. Hold still instead and it still vanishes after 3s. Press
        Escape: nothing. At 400% zoom the card can overflow the screen and there is no
        way to dismiss it without moving the pointer.
      </p>

      <p className="mt-2 text-xs text-destructive">
        Not hoverable, not persistent, not dismissible — WCAG 1.4.13 fails on all three
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-hover-content-persistence-good`

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

export function HoverContentPersistenceGood() {
  const [open, setOpen] = useState(false);

  // DISMISSIBLE: Escape closes the card without moving the pointer or focus.
  useEffect(() => {
    if (!open) return;
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        e.stopPropagation();
        setOpen(false);
      }
    };
    window.addEventListener('keydown', onKeyDown, true);
    return () => window.removeEventListener('keydown', onKeyDown, true);
  }, [open]);

  return (
    <div className="w-full max-w-sm">
      <div className="rounded-lg border border-border bg-card p-4">
        <p className="text-sm text-foreground">
          Assigned to{' '}
          {/* The wrapper spans BOTH the trigger and the card, so the pointer never
              leaves it on the way over. HOVERABLE. */}
          <span
            className="relative inline-block"
            onMouseEnter={() => setOpen(true)}
            onMouseLeave={() => setOpen(false)}
          >
            <button
              type="button"
              onFocus={() => setOpen(true)}
              className="font-medium text-primary underline underline-offset-2 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              @dana
            </button>

            {open && (
              // pt-2 is an invisible bridge: the visual gap is still there, but it
              // is INSIDE the hoverable region, so crossing it never fires mouseleave.
              // No timer — the card persists until dismissed. PERSISTENT.
              <span className="absolute left-0 top-full z-10 block pt-2">
                <span className="block w-56 rounded-lg border border-border bg-popover p-3 text-xs text-popover-foreground shadow-md">
                  <span className="block font-medium">Dana Okafor</span>
                  <span className="mt-1 block text-muted-foreground">
                    Staff engineer · Platform
                  </span>
                  <button
                    type="button"
                    className="mt-2 block text-primary underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
                  >
                    View profile
                  </button>
                </span>
              </span>
            )}
          </span>{' '}
          for review.
        </p>
      </div>

      <p className="mt-3 text-xs text-muted-foreground">
        Hover <em>@dana</em> and walk your pointer down into the card — it stays, and{' '}
        <em>View profile</em> is clickable. Park on it as long as you like; nothing
        expires. Press Escape and it closes with the pointer exactly where it was, so a
        magnifier user who needs the card out of the way is not forced to lose their
        place.
      </p>

      <p className="mt-2 text-xs text-success">
        Hoverable, persistent, and dismissible — all three parts of WCAG 1.4.13
      </p>
    </div>
  );
}
```

## References

- [WCAG 2.1: Understanding SC 1.4.13 Content on Hover or Focus](https://www.w3.org/WAI/WCAG21/Understanding/content-on-hover-or-focus.html)
