π 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
GestureDetectororInkWellthat 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
- Connect a physical keyboard (or enable keyboard simulation on the emulator).
- Press Tab to move focus through interactive elements.
- Verify that a visible focus indicator appears on every focusable widget.
- 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);
});