πŸ” Focus indication

Common mistake

A common mistake is omitting visible focus indicators from interactive elements. When an element receives keyboard focus, there is no visual change to signal its active state. Users who navigate with a keyboard or switch device therefore lose track of their position in the application, which makes the interface effectively unusable for them.

🎯 Relevant elements

This guideline applies to every interactive widget that can receive keyboard focus:

  • Buttons (ElevatedButton, TextButton, IconButton, custom buttons)
  • Navigation bar items
  • List items and cards that are tappable
  • Any widget wrapped in GestureDetector or InkWell that is reachable by keyboard

WCAG Guideline

This guideline is based on WCAG 2.2 β€” 2.4.7 Focus Visible (Level AA). Any keyboard-operable user interface must have a mode of operation where the keyboard focus indicator is visible.


Solution

Flutter does not render a visible focus ring by default on mobile targets. These are created by the operating system itself. You must implement focus indication explicitly when the operating system’s default indicator is not sufficient or is disabled. There are two common approaches.

Option A: Custom focus builder / wrapper with FocusNode

Create a stateful wrapper that attaches a FocusNode to the child widget and rebuilds when focus changes. When focusNode.hasFocus is true, overlay a coloured border on top of the widget using Positioned.fill.

// ❌ Don't - interactive widget with no visible feedback when focused by keyboard
InkWell(
  onTap: onTap,
  child: child,
);

// βœ… Do - wrap the interactive widget in a custom focus builder that renders a border
class FocusIndicatorBuilder extends StatefulWidget {
  const FocusIndicatorBuilder({required this.child, super.key});

  final Widget child;

  @override
  State<FocusIndicatorBuilder> createState() => _FocusIndicatorBuilderState();
}

class _FocusIndicatorBuilderState extends State<FocusIndicatorBuilder> {
  final FocusNode _focusNode = FocusNode();

  @override
  void initState() {
    super.initState();
    _focusNode.addListener(_onFocusChange);
  }

  void _onFocusChange() => setState(() {});

  @override
  void dispose() {
    _focusNode.removeListener(_onFocusChange);
    _focusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final accentColor = Theme.of(context).colorScheme.secondary;

    return Focus(
      focusNode: _focusNode,
      child: Stack(
        children: [
          widget.child,
          if (_focusNode.hasFocus)
            Positioned.fill(
              child: IgnorePointer(
                child: DecoratedBox(
                  decoration: BoxDecoration(
                    border: Border.all(color: accentColor, width: 3),
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

Usage:

// Works with any interactive widget: buttons, list items, cards, etc.
FocusIndicatorBuilder(
  child: InkWell(
    onTap: onTap,
    child: child,
  ),
);

Option B: WidgetStateProperty for Material widgets

Material widgets that expose a style parameter - such as ElevatedButton, TextButton, IconButton, NavigationBar, and SegmentedButton - accept WidgetStateProperty values so you can target the focused state directly through their style API. This is the simpler approach when no custom wrapper is needed.

// βœ… Do - use WidgetStateProperty.resolveWith to style the focused state
// Example shown with ElevatedButton; the same pattern applies to any
// Material widget that exposes WidgetStateProperty style properties.
ElevatedButton(
  onPressed: onPressed,
  style: ButtonStyle(
    backgroundColor: WidgetStateProperty.resolveWith<Color?>(
      (states) {
        if (states.contains(WidgetState.focused)) {
          return Theme.of(context).colorScheme.secondary.withOpacity(0.2);
        }
        return null; // use default
      },
    ),
    side: WidgetStateProperty.resolveWith<BorderSide?>(
      (states) {
        if (states.contains(WidgetState.focused)) {
          return BorderSide(
            color: Theme.of(context).colorScheme.secondary,
            width: 3,
          );
        }
        return null;
      },
    ),
  ),
  child: Text(label),
);

Choose the custom focus builder for custom or non-Material widgets. Choose WidgetStateProperty for widgets that already support WidgetStateProperty style properties.


Validation & Testing

See the Validation & Testing setup guide for tool configuration.

Manual testing

  1. Connect a physical keyboard (or enable keyboard simulation on the emulator).
  2. Press Tab to move focus through interactive elements.
  3. Verify that a visible focus indicator appears on every focusable widget.
  4. Verify the indicator has sufficient colour contrast against its background (minimum 3:1 ratio as per WCAG 2.4.11).

TalkBack & VoiceOver

Navigate through the screen using swipe gestures. Each interactive element should receive a visible highlight (TalkBack green rectangle / VoiceOver dark rectangle) when it is the current focus target.

flutter_test

Use FocusNode and tester.pump() to assert that the focus indicator is rendered when a widget gains focus.

testWidgets('focus indicator is visible when button receives focus',
    (tester) async {
  final focusNode = FocusNode();

  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: FocusIndicatorBuilder(
          child: Focus(
            focusNode: focusNode,
            child: ElevatedButton(
              onPressed: () {},
              child: const Text('Press me'),
            ),
          ),
        ),
      ),
    ),
  );

  // No focus indicator before focus
  expect(find.byType(DecoratedBox), findsNothing);

  focusNode.requestFocus();
  await tester.pump();

  // Focus indicator (DecoratedBox with border) is rendered
  expect(find.byType(DecoratedBox), findsOneWidget);
});

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