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.
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:
- 63+ IconButtons without tooltips, meaning screen readers would announce "button" with no description
- No high-contrast theme, required for the Microsoft Store "Accessible" badge
- 18 GestureDetector widgets that weren't keyboard-accessible (only responded to tap/click)
- 30+ progress indicators invisible to screen readers
- 25+ text elements with color contrast below WCAG AA requirements
- No automated accessibility tests in the CI/CD pipeline
- No focus traversal management in the main navigation layout
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:
- SemanticsDebugger toggle β A Riverpod provider that conditionally wraps the app in Flutter's
SemanticsDebuggerin debug builds, letting developers visually inspect the semantic tree - High-contrast themes β Two new theme variants (
highContrastLightThemeandhighContrastDarkTheme) using Flutter'sColorScheme.highContrastLight()/.highContrastDark()constructors, with 2px borders on cards and buttons, and explicit black/white color schemes - Accessibility test helpers β A reusable list of Flutter accessibility guidelines (
androidTapTargetGuideline,iOSTapTargetGuideline,labeledTapTargetGuideline,textContrastGuideline) for use in widget tests
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".
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.
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
- FocusTraversalGroup with
OrderedTraversalPolicyadded to the main navigation body, ensuring logical tab order across the app bar, content area, and bottom navigation - Drawer accessibility β The current project card in the navigation drawer got a
Semanticswrapper announcing "Current project: [name]" - Analysis running banner β The background analysis indicator got
Semantics(liveRegion: true)so screen readers announce when analysis starts
TestPhase 4: Automated Testing & CI/CD
A comprehensive accessibility test suite was created that verifies:
- All four themes (light, dark, high-contrast light, high-contrast dark) pass text contrast guidelines
- Common widget layouts pass tap target size guidelines (48x48dp Android, 44x44pt iOS)
- All tappable elements have labels for screen readers
- The
LoadingIndicatorwidget correctly provides semantic labels
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
- FloatingActionButton without tooltip: The “Create new task” FAB in the task manager was announced as just “button”. Fixed by adding
tooltip: 'Create new task'. - InkWell panel tabs ignoring tooltip parameter: The workspace sidebar’s panel tabs accepted a
tooltipparameter in the builder method but never applied it to the widget tree. Wrapped withTooltipandSemanticswidgets. - Sidebar toggle with no semantic label: The workspace status bar’s sidebar toggle was an
InkWellwith no accessibility information. AddedSemantics(label: ..., button: true)wrapper with dynamic label based on state. - Tooltip() wrapper anti-pattern: ~10
IconButtonwidgets across the codebase used aTooltip(child: IconButton(...))wrapper instead of the built-intooltip:parameter. The wrapper pattern may not feed correctly into the semantics tree for screen readers. Converted all to use thetooltip:parameter directly.
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 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:
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:
- 205 IconButtons wrapped with
Semantics(label: ...)across 93 files - 17 PopupMenuButtons wrapped across 14 files (these had no accessible name at all)
- 5 FloatingActionButtons wrapped across 5 files
- Quick Action cards in the workspace welcome screen and dashboard wrapped with
Semantics(label: ..., button: true) - Tab close buttons wrapped with descriptive labels like “Close Welcome tab”
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.
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.
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”.
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.
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:
- Welcome screen — Clean (no issues detected after prior phases)
- Task management — MergeSemantics on Switch, Semantics on card GestureDetectors
- File browser — 20+ arrow tap targets enlarged, row heights standardized to 28px minimum
- Mega report dialog — TextButton minimum height fix
- Mega report view — 5 SelectableText conversions, IconButton constraint fixes
Key Decisions
- Category-based vs. screen-based fixes: Fixing all tooltips at once (rather than going screen by screen) ensured consistent naming patterns and made it easy to verify completeness with a single script.
-
Fix the source, not the symptoms: Updating the shared
LoadingIndicatorwidget with Semantics automatically fixed every screen that used it β a single edit that propagated across the entire app. -
Intentional GestureDetector retention: Not every
GestureDetectorneeds replacing. Terminal focus handlers and modal backdrop dismissals serve specific UX purposes thatInkWelldoesn't handle well. We replaced only the ones that needed keyboard accessibility (checkboxes, URL links, color pickers, tab switching). - 0.7 opacity minimum: Rather than computing exact contrast ratios for each background/foreground combination, we established 0.7 as the minimum alpha for any text or meaningful icon. This provides sufficient contrast across all theme variants.
Results
- All 55 screens are navigable with VoiceOver (macOS) and keyboard-only
- Every interactive widget (234 buttons, menus, and cards) has an explicit accessible name announced by VoiceOver
- All tap targets meet a 28×28 minimum size, verified at runtime with
accessibility_tools - High-contrast themes activate automatically with OS accessibility settings
- Automated accessibility tests run on every PR in CI/CD
- Runtime accessibility overlay available in debug builds for ongoing verification during development
- The app targets Microsoft Store "Accessible" badge requirements (Narrator support, keyboard navigation, high contrast, DPI support)
- The public accessibility statement has been updated to reflect full WCAG 2.1 AA compliance
Lessons Learned
- Tooltips are the #1 quick win β Adding
tooltip:to everyIconButtonis the single most impactful accessibility improvement in a Flutter app. It's trivial to do and makes a massive difference for screen reader users. - Flutter's high-contrast support is simple β
ColorScheme.highContrastLight()and thehighContrastThemeparameter onMaterialApphandle the OS integration automatically. The main work is designing the theme itself. liveRegion: truemakes progress visible β Without it, screen readers have no way to know something is loading. With it, users get real-time updates on build progress, file sync, and analysis status.- Audit before fixing β Using scripts to find all instances of each issue type (missing tooltips, low-contrast text, etc.) prevents the "whack-a-mole" pattern of fixing one screen and missing another.
- Manual screen reader testing is irreplaceable β Automated tests catch missing labels and contrast issues, but only manual VoiceOver/Narrator testing reveals how buttons are actually announced. The
Tooltip(child: IconButton)anti-pattern was invisible to automated tools but broken for real users. tooltip:alone is not enough on Flutter desktop β Flutter’sIconButtoncreates aSemantics(container: true)boundary that traps the tooltip’s semantic label inside it. On macOS, VoiceOver may not read it. Always add an explicitSemantics(label: ...)wrapper outside the button to guarantee the accessible name is announced.- Automate large-scale refactors β With 234 widgets to fix, manual editing was impractical. A Python script that parsed the source files, extracted tooltip values, and wrapped each widget completed the entire transformation in seconds with zero syntax errors.
- Runtime testing catches what static analysis misses β The
accessibility_toolspackage found 40+ issues that static analysis, widget tests, and even manual VoiceOver testing missed.SelectableTextcreating invisible interactive handles,BoxConstraints()allowing 20×20 buttons, and 16×16 tree arrows were all undetectable without inspecting the rendered widget tree at runtime. - Layer your testing approaches β No single technique catches everything. Our most effective strategy combined four layers: static analysis (
flutter analyze), automated widget tests (CI/CD), manual screen reader testing (VoiceOver), and runtime accessibility overlays (accessibility_tools). Each layer found issues the others missed. SelectionAreaeliminates the need forSelectableTextβ Wrapping the app inSelectionAreaenables text selection on allTextwidgets globally. IndividualSelectableTextwidgets then become redundant — and they create accessibility problems by generating interactive selection handles that lack semantic labels.