🔍 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 get created by the operating system itself/ You must implement focus indication explicitly if the default indicator of the operating system is not sufficient or disabled. There are two common approaches.

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

🛠️ Work in progress…


🛠️ Usage baseflow-a11y-components library

🛠️ Work in progress…


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