Accessibility is a coding standard, not a checklist
Accessibility is authored into the widget as you write it — the same tier as "the button actually works." There is no lint for any of this:
and
ship zero a11y rules. Enforcement is the widget itself, review of your own diff, and widget tests. Applies to every interactive View, every
/
, every state, and every color choice.
Testing mechanics (guideline matchers, golden lanes, RTL, honest conformance limits) live in
widget-golden-and-a11y-testing
; this skill is the authoring discipline.
Non-negotiable rules
- Every interactive node gets
Semantics(button: true, label: ...)
— a raw with no semantics silently locks out every screen-reader and switch user. Correct semantics make TalkBack, VoiceOver, Switch Access and Switch Control work for free; it is definition-of-done, never a backlog ticket.
- Every / gets a , or is wrapped in because it is decorative. There is no third option — an unlabelled is invisible to a screen reader.
- Read a11y state from at build time, never from app state. is an with correct-by-construction invalidation; pushing it through a provider trades a compiler-guaranteed rebuild for a frame of staleness in the one area where being wrong is total failure.
- Never
MediaQuery.withClampedTextScaling
, never . Both are clamping hacks that silently defeat the text-scale matrix while contrast and tap-target guidelines still pass green. Enforce with a source grep (see Anti-patterns).
- Never , computed , or to make a label fit. They turn "doesn't fit at 200%" from a loud test failure into truncated or shrunk text on a device. Build flexible heights and let text wrap.
- Never encode state through color alone. Pair every stateful signal with at least one non-color channel — icon + text label + shape/pattern + position. Color-only state is invisible under , grayscale color-correction, and to every screen-reader user.
- Contrast is measured against the composited background in both themes — body/icon text ≥ 4.5:1 (AA 1.4.3), large/hero text ≥ 3:1 (AA) — large text never has to clear a higher bar than body. Never test against the nominal token color when a fill, gradient, or translucency sits between text and surface.
- Every interactive target is ≥ 44×44 logical pixels and single-tap. No long-press or precise-gesture-only affordances; hand-off and motor-impaired use demand a large forgiving target.
- Traversal order is authored with , never inherited from layout — visual position optimized for the thumb actively pessimises linear screen-reader/switch scanning.
- Honor ; keep haptics through reduced motion. Hardcoding throws the setting away. Read from here, but resolve how motion collapses through 's reduced-motion token + helper (that skill owns the mechanism) — an animation that still conveys meaning with no reduced-motion path is a bug.
Where a11y state comes from
Read platform accessibility state from
, app state from your notifier:
dart
@override
Widget build(BuildContext context) {
final boldText = MediaQuery.boldTextOf(context);
final highContrast = MediaQuery.highContrastOf(context);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
// textScaler is deliberately NOT read and NOT clamped. Text scales itself.
...
}
MediaQuery.highContrastOf
is
iOS-only and always false on Android (
AccessibilityFeatures.highContrast
is documented "Only supported on iOS"). Read the flag opportunistically; never gate anything on it, and provide an in-app contrast/theme control as the mechanism that works everywhere.
is a system compositing filter — never reimplement it; just never encode meaning in color alone, or an inverted screen changes what the UI says.
The tap target, wrong and right
dart
// WRONG — invisible to a screen reader, a clamping hack, a hidden overflow bug
class ItemTile extends StatelessWidget {
const ItemTile({super.key, required this.item, required this.onTap});
final Item item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return MediaQuery.withClampedTextScaling( // (1) BANNED clamp
maxScaleFactor: 1.3,
child: GestureDetector(
onTap: onTap,
child: Column(
children: [
const Icon(Icons.star), // (2) no semanticLabel
Text(
item.title, // (3) no Semantics button role
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.normal, // (4) ignores boldText
),
overflow: TextOverflow.ellipsis, // (5) HIDES the overflow bug
),
],
),
),
);
}
}
dart
// RIGHT — semantics authored, text free to scale, target ≥ 44px, non-color state
class ItemTile extends StatelessWidget {
const ItemTile({super.key, required this.item, required this.onToggle});
final Item item;
final void Function(ItemId id) onToggle; // resolve at tap from the stable id
@override
Widget build(BuildContext context) {
final bold = MediaQuery.boldTextOf(context);
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context); // non-null getter (i18n-rtl-l10n owns this contract)
return Semantics(
container: true,
button: true,
label: item.title, // display label, not a sentence
// Non-color state: read out AND flip an icon (below), never color alone.
value: item.isSelected ? l10n.selected : l10n.notSelected, // localized, not literal
sortKey: OrdinalSortKey(item.priority.toDouble()), // priority, not layout
child: GestureDetector(
behavior: HitTestBehavior.opaque, // whole cell is the target
onTap: () => onToggle(item.id), // stable id, no stale-closure capture
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
child: ExcludeSemantics( // face already announced above
child: Row(
children: [
Icon(item.isSelected ? Icons.check_circle : Icons.circle_outlined),
Text(
item.title, // no ellipsis, no FittedBox
style: theme.textTheme.bodyLarge!.copyWith(
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
),
),
);
}
}
The
is the
display label, never a longer vocalization — nothing in the type system distinguishes the two
s, so a scanning user hears the whole sentence on every step unless you keep them separate. Resolve the action from the immutable id
at tap time; capturing a mutable field into the closure fires the stale value on a fast re-tap.
Text scale: the instinct is the bug
The reflex when a fixed layout overflows at 200% is to disable text scaling for stability. That reflex is the defect. Auto-shrinking —
, computed
,
+
— is the identical bug in disguise: the layout stays tidy and the user's setting stops working, silently, with every guideline still green.
- Honor
MediaQuery.textScalerOf(context)
by not touching it. Flutter text widgets already scale.
- is deprecated in favour of precisely to support Android's nonlinear scaling; its use is almost always a clamp.
- Build intrinsic/flexible heights; let text wrap to as many lines as it needs. Hero and display sizes may cap their own growth and wrap to a second line rather than truncate.
- Reflow horizontal rows (chip bars, toolbars) at accessibility text sizes instead of clipping.
- Use
FontFeature.tabularFigures()
(or -style tabular numerals) wherever numbers change or align so they don't reflow or jitter.
- Let overflow scream in tests. A red/yellow overflow stripe in a widget test is the feedback loop; a truncated word on a device is a failure nobody reports.
Never state through color alone
A real state — selected, active, pending, complete, error, disabled — is knowable without color. Encode it redundantly across independent channels:
- Icon / glyph whose shape differs per state (grayscale-legible).
- Text label — the word for the state, routed through localization.
- Shape / pattern / outline — a monotonic stripe, keyline, or notch.
- Position — sort the item that needs attention to the top under a header.
Expose the state to assistive tech through
— a
/
, or a
for a transient announcement — never through a luminance step alone:
dart
Semantics(
liveRegion: true, // announced when it changes, e.g. "Sync complete"
label: statusLabel(status), // the word, not the color
child: StatusBadge(status: status), // icon + label + shape, color is decoration
)
Pair visual feedback with
HapticFeedback.selectionClick()
so confirmation survives reduced motion and low vision. Guard any latched "active" visual with a timeout that force-clears it — a stuck state is both a visual lie and a stale semantic value.
Contrast against the composited background
Verify contrast against what the pixel actually composites to, in both light and dark, not against the nominal token:
- Body text and meaningful icons/outlines ≥ 4.5:1 (AA 1.4.3); push to 7:1 (AAA 1.4.6) for body where you can reach it. Large/hero text ≥ 3:1 (AA) or 4.5:1 (AAA) — large text is never required to exceed body.
- Never render a critical number or label on a gradient, translucent glass, or chip fill; put it on an opaque surface.
- Any accent fill that carries text must be pre-paired with a foreground that clears 4.5:1 on that fill, re-verified in dark mode (dark values are hand-tuned, never auto-flipped).
- Avoid the red/green trap: a neutral value is neutral ink, "success" is green + ✓ glyph + the word, danger-red appears only on destructive actions.
- Verify the whole UI in a grayscale pass and deuteranopia / protanopia / tritanopia simulation.
Traversal order is a design decision
Layout optimized for thumb reach (highest-priority controls low and central) combined with Flutter's default row-major traversal makes the most important control the last thing a linear scanner reaches. Decouple traversal from position with one argument:
dart
Semantics(
sortKey: OrdinalSortKey(item.priority.toDouble()), // authored from priority
... )
Assert
order equals priority order (not layout order) in a test. Know the limit: Flutter publishes no Switch Access support statement and no API simulates scanning or group selection, so this is a regression guard on
intent — real-device passes with TalkBack, VoiceOver, and Switch Access are the only conformance evidence. Touch, switch, and screen reader are three different channels: design for all three or state plainly which one was dropped.
Anti-patterns
MediaQuery.withClampedTextScaling
/ — the one-line "fix" a future contributor reaches for when an overflow test goes red; it defeats the entire text-scale matrix silently. Ban with grep -rn "withClampedTextScaling\|textScaleFactor" lib/
in CI.
- / computed / on a real label — same bug wearing a disguise; the setting stops working while the layout stays tidy.
- Unlabelled / — invisible to a screen reader; must be labelled or .
- Reading // from a provider — one frame stale, in the area where wrong is total failure.
- Gating anything on — always false on Android; it silently disables your feature on the majority platform.
- Color as the only channel for a state — dies under , grayscale mode, and screen readers.
- Capturing a mutable field into an closure — fires the stale value on a fast re-tap; resolve from the stable id at tap time.
- A precise-gesture-only or long-press-only affordance — excludes motor-impaired and hand-off use; provide a ≥44px single tap.
- in tests — deprecated; use .
Definition of done
For any widget with a tap target or state:
- node with , a display-only , and an authored .
- Every / labelled or explicitly .
- No clamp, no , no on a real label; renders at without overflow.
- honored; no hardcoded on user-facing text.
- Every state carries a non-color channel; transient state uses or ; feedback includes a haptic.
- Contrast verified against the composited background in both themes (≥4.5:1 body / ≥3:1 large) and in a grayscale + CVD pass.
- Interactive targets ≥ 44×44 and single-tap.
- A test asserting the semantics with and traversal against priority order.
Related skills
widget-golden-and-a11y-testing
— the guideline matchers, golden lanes, RTL goldens, and honest a11y-conformance limits that verify this authoring.
- — state labels and announcements routed through gen-l10n/ARB, and Directional geometry for correct-by-construction RTL.
- — token→theme layering, hand-authored , the no-raw-values gate that keeps contrast pairings honest, and the reduced-motion token + helper it owns (this skill reads the flag, that skill resolves the motion).
state-management-riverpod
— the split both skills co-own: app/domain state flows through Riverpod notifiers, while platform/a11y flags are read from / and never routed through a provider.
- — small const Views, dumb widgets, and dispose discipline that this semantics work sits on top of.
- — the end-of-build screenshot sweep (light/dark × RTL × largest text × reduce-motion) where a11y floors are always blocking.
References