Making a Flutter Desktop App Fully Accessible

How we brought CodeFrog's macOS and Windows desktop app to WCAG 2.1 Level AA compliance across 55 screens and 22 feature modules.

February 2026 Flutter / Dart macOS & Windows WCAG 2.1 AA
55
Screens Updated
234
Semantics Labels Added
30+
Progress Indicators Labeled
25+
Contrast Issues Fixed
40+
Runtime Issues Caught

The Challenge

CodeFrog is a feature-rich developer tool with 55 screens across 22 feature modules, including a code editor, SSH terminal, GitHub integration, build orchestration, task management, and web testing. While the app had some accessibility support (a handful of Semantics widgets and about 50 tooltips), a comprehensive audit revealed significant gaps:

The Approach

Rather than addressing accessibility screen-by-screen, we took a systematic, category-based approach β€” fixing all instances of each accessibility issue type across the entire codebase in a single pass. This ensured consistency and made it harder to miss edge cases.

InfrastructurePhase 1: Developer Tooling

Before fixing individual screens, we built the infrastructure needed to develop and verify accessibility:

The high-contrast themes are wired into MaterialApp.router via the highContrastTheme and highContrastDarkTheme parameters, so they activate automatically when the OS setting is enabled β€” no user action required.

FixPhase 2: Systematic Screen Fixes

2.1 Tooltips on all IconButtons (65+ fixes)

Every IconButton without a tooltip parameter was identified using a script that parsed the AST of all Dart files. Tooltips were added with descriptive text like "Go back", "Toggle password visibility", "Clear search", etc. This is the single highest-impact accessibility fix β€” without tooltips, screen readers announce every icon button as just "button".

// Before: screen reader announces "button" IconButton( onPressed: _goBack, icon: const Icon(Icons.arrow_back), ) // After: screen reader announces "Go back, button" IconButton( onPressed: _goBack, icon: const Icon(Icons.arrow_back), tooltip: 'Go back', )

2.2 Semantic labels on all images (7 files)

Every Image.asset widget received a semanticLabel parameter. Informative images got descriptive labels; decorative images would use ExcludeSemantics.

2.3 Keyboard-accessible replacements for GestureDetectors

GestureDetector widgets only respond to pointer events β€” they can't receive keyboard focus or be activated with Enter/Space. We replaced them with InkWell (which supports focus and keyboard activation) wrapped in Semantics where appropriate. Terminal focus handlers and modal backdrop dismissals were intentionally left as GestureDetector since they serve different purposes.

2.4 Semantics on progress indicators (30+ instances)

Every CircularProgressIndicator and LinearProgressIndicator was wrapped with Semantics(liveRegion: true) so screen readers announce loading states and progress updates. The core LoadingIndicator widget (used app-wide) was fixed at the source, automatically propagating to all consumers.

Semantics( label: 'Build progress: ${buildState.percentage}%', liveRegion: true, child: LinearProgressIndicator( value: buildState.percentage / 100.0, ), )

2.5 Color contrast fixes (25+ instances)

Text elements using .withValues(alpha: 0.5) or .withValues(alpha: 0.6) were bumped to 0.7 minimum to meet WCAG AA contrast ratios. The bottom navigation bar's unselected item colors in both light and dark themes were also updated.

FixPhase 3: Focus Management

TestPhase 4: Automated Testing & CI/CD

A comprehensive accessibility test suite was created that verifies:

These tests run as a dedicated step in the GitHub Actions CI/CD pipeline, before the main test suite. Any PR that breaks accessibility compliance is caught automatically.

TestPhase 5: Manual VoiceOver Testing

After deploying the automated fixes, we performed manual VoiceOver testing on macOS. This revealed issues that automated tests cannot catch — specifically, buttons that VoiceOver announced as just “button” without any description of what they do.

Issues Found

// Anti-pattern: Tooltip wrapper may not convey to VoiceOver Tooltip( message: 'Export as CSV', child: IconButton( icon: const Icon(Icons.table_chart), onPressed: _onExport, ), ) // Correct: tooltip parameter feeds into semantics tree IconButton( icon: const Icon(Icons.table_chart), tooltip: 'Export as CSV', onPressed: _onExport, )

This phase underscored that automated accessibility testing catches structural issues (missing labels, contrast ratios) but cannot replace manual screen reader testing. VoiceOver testing revealed semantic gaps that were invisible to static analysis and automated CI checks.

FixPhase 6: Explicit Semantics Wrappers (234 Widgets)

While Phase 5 fixed a handful of edge cases, continued VoiceOver testing revealed a deeper, systemic issue: even buttons with tooltip: set correctly were still being announced as just “button” by VoiceOver on macOS.

Root Cause

Flutter’s Material 3 IconButton internally creates a Semantics(container: true, button: true) node. The container: true flag establishes a semantics boundary. When the tooltip: parameter is set, Flutter wraps the icon in a Tooltip widget, which adds its own Semantics(label: ...) node — but this label lives inside the container boundary. On macOS, VoiceOver sees the outer container (a button with no label) and the inner label as separate semantics nodes, so it announces only “button”.

// The semantics tree with just tooltip: Semantics(container: true, button: true) // ← VoiceOver reads this: "button"InkResponseTooltip(message: 'Settings') → Semantics(label: 'Settings') // ← trapped inside containerIcon(Icons.settings)

The Fix

Wrapping each button with an explicit Semantics(label: ...) at the same level or above the container boundary ensures the accessible name is available to VoiceOver:

// Before: VoiceOver announces "button" IconButton( icon: const Icon(Icons.settings), tooltip: 'Settings', onPressed: _openSettings, ) // After: VoiceOver announces "Settings, button" Semantics( label: 'Settings', child: IconButton( icon: const Icon(Icons.settings), tooltip: 'Settings', onPressed: _openSettings, ), )

Scale

This issue affected every interactive widget in the app. We wrote a Python script to automate the transformation, processing the entire codebase in a single pass:

After applying all changes, dart format cleaned up indentation and flutter analyze confirmed zero new issues.

TestPhase 7: Runtime Accessibility Testing with accessibility_tools

After our automated CI tests and manual VoiceOver testing, we integrated the accessibility_tools package (v2.8.0) as a debug-only overlay. Unlike static analysis or widget tests, this tool checks accessibility at runtime — inspecting the actual rendered widget tree as you navigate the app, flagging issues like undersized tap targets, missing semantic labels, and unlabeled text fields in real time. Runtime overlays are automated checks, so we still pair them with manual assistive-technology testing for full WCAG conformance.

Integration

The package wraps the app’s widget tree with an AccessibilityTools widget in debug mode only. When an issue is detected, a colored overlay appears on the offending widget and a detailed log prints to the console with the widget type, file path, line number, and the exact rendered size.

// main.dart — debug-only accessibility overlay import 'package:accessibility_tools/accessibility_tools.dart'; MaterialApp.router( builder: (context, child) { return AccessibilityTools( checkSemanticLabels: true, checkFontOverflows: true, // Platform guidelines: 48×48 dp (Android), 44×44 pt (iOS). // We use 48 for mobile and 28 for desktop (macOS sidebar rows). minimumTapAreas: const MinimumTapAreas( mobile: 48, desktop: 28, ), child: child ?? const SizedBox.shrink(), ); }, )

Issues Discovered

Runtime testing revealed categories of issues that were invisible to both static analysis and manual VoiceOver testing:

7.1 SelectableText flagged as unlabeled with undersized tap targets

SelectableText widgets create interactive selection handles that the tool correctly flagged as missing semantic labels and having tap areas too small. Since our MaterialApp already wraps the entire app in a SelectionArea (enabling text selection globally), these were safely converted to plain Text widgets — fixing both the label and size issues at once.

// Before: flagged for missing label + small tap targets SelectableText( 'Report completed', style: theme.textTheme.titleMedium, ) // After: SelectionArea in main.dart handles selection globally Text( 'Report completed', style: theme.textTheme.titleMedium, )

7.2 GestureDetector tap targets too small (16×16 file tree arrows)

The file browser’s expand/collapse arrows used a GestureDetector wrapping a 16×16 icon — far below the 28×28 minimum. Each was replaced with a 28×28 SizedBox containing a centered 14px icon, wrapped in Semantics with a dynamic label like “Expand src” or “Collapse lib”.

// Before: 16×16 tap target, no semantic label GestureDetector( onTap: () => _toggleExpansion(node), child: Container(width: 16, height: 16, child: Icon(Icons.keyboard_arrow_right, size: 16)), ) // After: 28×28 tap target with semantic label Semantics( button: true, label: 'Expand ${item.name}', child: GestureDetector( onTap: () => _toggleExpansion(node), child: SizedBox(width: 28, height: 28, child: Center(child: Icon(Icons.keyboard_arrow_right, size: 14))), ), )

7.3 IconButton constraints allowing sub-minimum sizes

Several IconButton widgets used constraints: const BoxConstraints() to remove Material’s default 48×48 minimum. While this made the layout tighter, it allowed the button to render as small as 20×20. The fix: explicit minimum constraints of 28×28.

// Before: renders at icon size (20×20) IconButton( icon: const Icon(Icons.copy_all, size: 20), constraints: const BoxConstraints(), padding: EdgeInsets.zero, ) // After: minimum 28×28 tap target IconButton( icon: const Icon(Icons.copy_all, size: 20), constraints: const BoxConstraints(minWidth: 28, minHeight: 28), padding: const EdgeInsets.all(4), )

7.4 Switch without merged semantics

A Switch widget next to a “Project Filter” label was announced by screen readers as two separate elements. Wrapping the row in MergeSemantics merged them into a single accessible element: “Project Filter, switch, on”.

7.5 TextButton.icon with zero minimum size

Compact text links like “Learn more about lawful use” used minimumSize: Size.zero, rendering at just 20px tall. Setting minimumSize: const Size(0, 28) and adding vertical padding brought them to the required minimum.

Runtime overlays are automated checks, so we still pair them with manual assistive-technology testing for full WCAG conformance.

Screens Improved

We navigated through the app screen by screen with the overlay active, fixing issues as they appeared:

Key Decisions

Results

Lessons Learned

← Back to Case Studies