🔍 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 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…