The accessible button that screen readers couldn't press

The accessible button that screen readers couldn't press
Tempy's cross-promo banner was, by every accessibility checklist we had, correct. It declared itself a button. It carried a full spoken label. TalkBack read it out. Then you double-tapped, and nothing happened.
The problem
The banner is a card: an icon, a headline, a line of body copy, a call to action, and above it a small disclosure that reads "From Eodin". The whole card is one tap target — it opens a store listing.
We had wrapped it like this:
Semantics(
button: true,
excludeSemantics: true,
label: [disclosure, headline, body, cta]
.where((s) => s != null && s.isNotEmpty)
.join('. '),
child: Material(
child: InkWell(onTap: _onTap, /* the card */),
),
)
That reads like the textbook version: one node instead of four, and no stutter from children announcing themselves after the parent already did.
When we finally measured the resulting semantics node, it looked like this:
flags: [isButton]
label: "From Eodin. <headline>. <body>. <cta>"
actions: []
actions: []. A button with no actions is a button that screen readers will happily announce — "From Eodin… button" — and then refuse to activate. Double-tap goes nowhere. For anyone navigating Tempy with TalkBack or VoiceOver, the banner was decoration that lied about being interactive.
Underneath it was a second, quieter failure. That "From Eodin" disclosure was 11px, and it was painted in gray400 on a white page. Measured: 2.2556:1. In dark mode, gray500 on the dark background: 4.06:1. The WCAG AA floor for text that size is 4.5:1, so both themes failed — and the light theme failed by a lot. It was also the first thing the card's ellipsis ate when the text scaler went up, because it lived inside the card's clipped column. The one label on the card that legally has to be legible was the one most likely to disappear.
Why it happened
excludeSemantics: true does exactly what it says, and slightly more than we read into it. It drops the entire subtree's semantics — not just the children's labels, which is what we wanted, but their actions too. InkWell's tap action is part of that subtree. We had deleted it.
The mental model we'd been carrying was "exclude the children's text so my label wins." The actual model is "this node replaces the subtree, in full." Everything the subtree contributed — labels, flags, actions, the lot — stops at that boundary. Our node was synthesized from scratch, and we never gave it an onTap, because we assumed the InkWell below was still supplying one.
Nothing complains about this. flutter analyze is clean, and the card renders and taps normally for sighted users. The only way to see the defect is to read the semantics node itself, or to hand a phone to someone using a screen reader.
What we tried first
The contrast half looked like a one-line change: swap the colour pair, done. It isn't, because the pair was wrong in both themes and for a reason that repeats — we had been measuring the disclosure against the card fill (2.99:1), when the label actually sits on the page background. Two different backgrounds, two different verdicts, and the number we'd checked was the one that didn't apply.
Moving the label out of the card so the ellipsis can't reach it was also more interesting than it sounds. The obvious version is to move the disclosure above the card and let the InkWell wrap the whole Column, keeping the entire thing one tap target. We tried that. The ink box grew to cover the label — +22dp at 1.0x text scale, +53dp at 3.0x — while borderRadius still rounded the card underneath. Hold a press and ink washes 22dp above the card, onto the page background, in a rounded rectangle that matches nothing on screen.
The fix
Two structural changes, landed together because they interact.
Keep button: true, drop excludeSemantics and the synthesized label, and let the children compose it:
Semantics(
button: true,
child: Column(
children: [
Padding( // outside the card: the card cannot clip it
padding: const EdgeInsets.only(left: 4, bottom: 6),
child: Text(disclosure, style: TextStyle(
fontSize: 11, color: AppColors.adKicker(isDark: isDark))),
),
Material(child: InkWell(onTap: _onTap, /* the card */)),
],
),
)
The InkWell wraps only the card, so the ink stays inside the rounded box. Semantics still merges the whole Column into a single node, so screen-reader focus and double-tap continue to cover the disclosure — the only thing given up is the 22dp strip of touch over the label itself.
And the colour became a shared token rather than a literal, because the AdMob "Sponsored" label three files away carried the byte-identical expression and the byte-identical bug:
static Color adKicker({required bool isDark}) => isDark ? gray400 : gray600;
One token, two call sites, and they can no longer drift apart.
Before and after
| Before | After | |
|---|---|---|
| Semantics actions | [] |
[tap, focus] |
| Disclosure contrast, light | 2.2556:1 | 7.53:1 |
| Disclosure contrast, dark | 4.06:1 | 7.23:1 |
| Disclosure clipped by card ellipsis | yes | structurally impossible |
| Ink overshoot above card | +22dp (1.0x) / +53dp (3.0x) | 0.0dp |
| Layout at 5 widths × 5 text scalers × 12 locales | — | bit-identical to baseline |
What we learned
excludeSemanticsexcludes actions, not just labels. If you synthesize a label on a node that wraps a gesture detector, you have to supply the gesture too — or let the children compose the label and keep the subtree intact. Abutton: trueflag is a promise;actions: []breaks it silently.A test that passes on the reverted code is not a test. Our contrast test compared two
AppColorsconstants to each other — a palette test wearing a banner test's name. We put the widget back to the exact defect and all six tests stayed green, control included. It now reads the colour off the renderedText. Same story for a test called "…and firing it opens the ad": guttingonTap: _onTaptoonTap: () {}didn't fail it, because it never fired anything.Pumping light then dark inside one widget test does not rebuild. Both pumps returned the light colour, so the dark assertion had been quietly testing light the whole time. One theme per test now.
Measure against the background the pixel actually sits on. We had a contrast number. It was for the card fill, and the label was on the page.
What's next
The honest status is that this is not verified. The acceptance test for the activation fix is TalkBack and VoiceOver on a physical device, and an assertion about a SemanticsNode in a unit test is not that. It's written down as a release blocker rather than quietly assumed.
While checking for siblings, the code review found the same contrast expression at eight more sites across four files. Six are chevrons, hints and a switch thumb — non-text, different threshold, fine. Two are readable text and fail the same way, and one of those is a hardcoded English "OR" separator, so it's a localization bug wearing a contrast bug's clothes. Different surfaces, each needing its own background measured, so they're filed separately rather than swept into this diff.
Full change: tempy@77901547.
Try Tempy
Tempy is a calm, offline-first fever log for parents — built so it survives 3 AM.
Frequently Asked Questions
Why did the accessible button in Tempy's banner fail to activate with screen readers?
The button used `excludeSemantics: true`, which removed the entire subtree's semantics including tap actions. Although the button was announced by screen readers, it had no associated actions, so double-tapping did nothing. The fix was to remove `excludeSemantics` and let children compose the semantics, preserving the tap action.
What accessibility issues were found with the 'From Eodin' disclosure label?
The disclosure label had insufficient color contrast against the background in both light and dark modes, failing WCAG AA standards. It was also clipped by the card's ellipsis when text scaling increased, making it potentially unreadable. The solution involved improving contrast ratios and restructuring the layout to prevent clipping.
How does `excludeSemantics: true` affect accessibility in Flutter widgets?
`excludeSemantics: true` removes the entire semantics subtree, including labels and actions. This means that if a gesture detector like `InkWell` is inside, its tap actions are also excluded, causing interactive elements to become non-functional for screen readers. Developers should avoid excluding semantics if they want to preserve interactivity.
What testing pitfalls were identified during the accessibility fixes?
Tests that compared color constants instead of rendered text colors gave false positives, and tests that didn't verify actual tap actions failed to catch broken interactions. Additionally, pumping light and dark themes in the same test without rebuilding caused incorrect color assertions. Proper testing requires verifying rendered output and real device screen reader behavior.
What structural changes improved the accessibility and layout of the banner button?
The disclosure label was moved outside the card to avoid clipping, and the `InkWell` was wrapped only around the card to keep ink effects within bounds. Semantics were composed by children without excluding semantics, preserving tap actions and merging the label into a single node. Color tokens were unified to maintain consistent contrast across themes.
Continue reading

The 57pt layout jump that moved a button mid-tap
A background query finished and Cubist's primary button slid 57pt up the screen. The three-line fix was easy; getting a test to see it was not.

How we cut public-page transfer 57% by deleting our bundle config
A webpack splitChunks block named "bundle optimization" was disabling Next.js per-route splitting and shipping admin-only libraries to 1,078 public pages.

The 12-second gap that swallowed two subscriptions
A parent bought an annual subscription. The entitlement landed 12 seconds late, so our purchase path read it as a failure and recorded nothing.