Skip to content

[material_ui] Add an opt-in keyboard focus ring for Material widgets - #13508

Open
flutter-zl wants to merge 3 commits into
flutter:mainfrom
flutter-zl:material-ui-focus-ring
Open

flutter-zl wants to merge 3 commits into
flutter:mainfrom
flutter-zl:material-ui-focus-ring

Conversation

@flutter-zl

@flutter-zl flutter-zl commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Part of flutter/flutter#186044

Problem

Material widgets show keyboard focus only as a state layer, the content color at 10% opacity. That tint tops out near 1.3:1 against its background, well below the 3:1 that WCAG 1.4.11 requires of a focus indicator, so keyboard users cannot reliably see which control is focused. Material 3 specifies a ring-like keyboard focus indicator for this, defined per component by the focus.indicator color, thickness and offset tokens, but material_ui never reads those tokens.

Fix

Adds FocusRingThemeData, FocusRingTheme and ThemeData.focusRingTheme. When visible is true, InkResponse draws a ring around the keyboard-focused control, only in FocusHighlightMode.traditional, so touch and mouse are unaffected. The new InkResponse.focusRingOffset places it per the M3 tokens: 2 px outside buttons and chips, 3 px inside list tiles, tabs and navigation destinations. The default ring is a 3 px secondary band with an onSecondary band inside it, because a single secondary ring measured 1.3:1 to 2:1 on primary app bars and dark snackbars. It is off by default and adds nothing to the widget tree when off, so existing apps are unchanged. Checkbox, Radio, Switch and Slider paint focus separately and will follow.

Demo

With focusRingTheme: const FocusRingThemeData(visible: true) in a test app, I tabbed through 20 screens of buttons, chips, list tiles, tabs, navigation bars, rails and drawers, dialogs, the date picker and snackbars, and measured the contrast between each control focused and unfocused.

Before After
Focus indicator contrast 1.14:1 to 1.32:1 4.99:1 to 13.18:1

Before: https://flutter-demo-90-before.web.app/#/text-button
After: https://flutter-demo-90-after.web.app/#/text-button
Press Tab to move focus. Other screens such as #/navigation-bar, #/dialog and #/snack-bar show the same difference.

@flutter-dashboard flutter-dashboard Bot added the CICD Run CI/CD label Oct 30, 2026
@github-actions github-actions Bot added p: material_ui triage-design Should be looked at in design triage labels Oct 30, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces FocusRingThemeData, FocusRingTheme, and ThemeData.focusRingTheme to support an opt-in Material 3 keyboard focus ring, alongside configuring component-specific offsets for buttons, chips, list tiles, tabs, and navigation destinations. Feedback suggests adding a guard in _strokeShape to handle non-positive dimensions from negative offsets, and optimizing FocusRingThemeData.lerp to avoid redundant object allocations when both arguments are null.

Comment on lines +1609 to +1610
void _strokeShape(Canvas canvas, Rect rect, double delta, Paint paint) {
final ShapeBorder? border = customBorder;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When drawing a focus ring with a negative offset (e.g., inside list tiles or tabs), the shape is deflated. If the widget's size is very small or animating to zero, the deflated rectangle can become inverted (where width or height is non-positive). This can cause rendering artifacts or assertions in RRect and canvas.drawRRect. Adding a guard at the start of _strokeShape to return early if the deflated dimensions are non-positive prevents these issues.

  void _strokeShape(Canvas canvas, Rect rect, double delta, Paint paint) {
    if (rect.width + 2 * delta <= 0 || rect.height + 2 * delta <= 0) {
      return;
    }
    final ShapeBorder? border = customBorder;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. _strokeShape now skips bands whose rect would invert, with a test for a 4x4 widget.

Comment on lines +92 to +95
static FocusRingThemeData lerp(FocusRingThemeData? a, FocusRingThemeData? b, double t) {
if (identical(a, b) && a != null) {
return a;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When both a and b are null, identical(a, b) is true, but a != null is false, which causes the method to fall through and instantiate a new FocusRingThemeData with all null fields. We can optimize this by returning a ?? const FocusRingThemeData() directly when identical(a, b) is true, avoiding unnecessary object allocation.

  static FocusRingThemeData lerp(FocusRingThemeData? a, FocusRingThemeData? b, double t) {
    if (identical(a, b)) {
      return a ?? const FocusRingThemeData();
    }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping this as is to match the other material_ui themes, such as BadgeThemeData.lerp. The null and null case already equals the const default.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD p: material_ui triage-design Should be looked at in design triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant