🖱️ Content on hover or focus

Common mistake

A common mistake is showing additional content (such as a tooltip or popover) when an element receives focus or is hovered, but providing no way to dismiss it without moving focus away. This is problematic for users who zoom in. The tooltip may obscure other content and for screen reader users who cannot move their pointer to hover over the revealed content.

🎯 Relevant elements

This guideline applies to any widget that reveals additional content on hover or focus:

  • Tooltips (Tooltip)
  • Popovers and contextual menus
  • Dropdown previews triggered by focus
  • Custom overlays revealed on hover or focus

WCAG Guideline

This guideline is based on WCAG 2.2 - 1.4.13 Content on Hover or Focus (Level AA). When additional content appears on hover or keyboard focus, it must meet three conditions:

  • Dismissable - the user can dismiss it without moving focus or the pointer (e.g. by pressing Escape).
  • Hoverable - the user can move the pointer over the revealed content without it disappearing.
  • Persistent - the content remains visible until the user dismisses it, moves focus away, or the information is no longer valid.

Solution

Flutter’s built-in Tooltip widget satisfies these conditions on most platforms. For custom overlays, ensure the same three properties are explicitly implemented.

// ❌ Don't - custom overlay dismisses immediately when the pointer leaves
// the trigger, giving users no time to read or interact with the content
MouseRegion(
  onEnter: (_) => setState(() => _showOverlay = true),
  onExit: (_) => setState(() => _showOverlay = false),
  child: MyTriggerWidget(),
);

// ✅ Do - use Flutter's Tooltip, which handles persistence and dismissal
Tooltip(
  message: 'Deletes the item permanently',
  child: IconButton(
    icon: Icon(Icons.delete),
    onPressed: () => deleteItem(),
  ),
);

For custom overlays that need to be hoverable and dismissable via Escape:

// ✅ Do - keep the overlay open while the pointer is over either the
// trigger or the overlay itself, and close on Escape
KeyboardListener(
  focusNode: _focusNode,
  onKeyEvent: (event) {
    if (event is KeyDownEvent &&
        event.logicalKey == LogicalKeyboardKey.escape) {
      setState(() => _showOverlay = false);
    }
  },
  child: MouseRegion(
    onEnter: (_) => setState(() => _showOverlay = true),
    // Do not close on exit here - handle this inside the overlay widget
    // so the user can move their pointer into the overlay content
    child: MyTriggerWidget(),
  ),
);

Validation & Testing

See the Validation & Testing setup guide for tool configuration.

Manual testing

  1. Trigger the overlay by hovering or tabbing to the element.
  2. Move the pointer into the revealed content - verify it stays visible.
  3. Press Escape - verify the content dismisses without moving focus.
  4. Move focus away - verify the content dismisses.

TalkBack & VoiceOver

Navigate to elements that trigger overlays. Verify that the revealed content is announced and reachable without requiring pointer interaction.

flutter_test

testWidgets('tooltip persists when pointer moves over it', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: Tooltip(
          message: 'Deletes the item permanently',
          child: IconButton(
            icon: Icon(Icons.delete),
            onPressed: () {},
          ),
        ),
      ),
    ),
  );

  // Hover over the trigger to reveal the tooltip
  final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
  await gesture.addPointer();
  await gesture.moveTo(tester.getCenter(find.byType(IconButton)));
  await tester.pumpAndSettle();

  expect(find.text('Deletes the item permanently'), findsOneWidget);
});

🛠️ Usage baseflow-a11y-components library

🛠️ Work in progress…


This site uses Just the Docs, a documentation theme for Jekyll.