Mobile Accessibility: Large Text, VoiceOver, and TalkBack
A code-centric guide to building accessible mobile UIs: semantic labels in Flutter, contentDescription on Android, accessibilityLabel on iOS, large text scaling, and 48dp touch targets.
Published on • August 18, 2026
AI Assistant

Accessibility is not a feature you bolt on at the end; it is a property of the architecture itself. For a screen reader user, your beautifully styled button might as well not exist unless the platform can announce it and your app can act on that announcement. The three pillars of mobile accessibility are semantic labeling, scalable text, and adequate touch targets. Get those right and TalkBack on Android, VoiceOver on iOS, and Flutter’s Semantics layer all work with you instead of against you.
In this post you will learn how to add semantic descriptions in Flutter, Android (Views and Compose), and iOS (SwiftUI and UIKit), how to make your UI survive font scaling up to 200% and beyond, and how to guarantee every interactive element meets the 48dp touch target guideline. Every snippet is runnable and practical.
Prerequisites
- Flutter 3.x with an Android or iOS simulator configured.
- Android Studio (for
contentDescriptionand Compose) with an API 30+ emulator. - Xcode 15+ for SwiftUI and the iOS Simulator.
- Basic familiarity with each platform’s layout system.
Why the Platform Needs You to Describe the UI
Screen readers like TalkBack and VoiceOver do not “see” pixels. They traverse a parallel structure — the accessibility tree — where every meaningful element carries a label, a role (trait), a value, and a set of actions. On Android this tree is built from View properties like contentDescription; on iOS it is built from UIAccessibilityElement-backed objects; in Flutter it is the Semantics tree that Flutter maintains on top of the widget tree.
Text widgets get announced automatically because their text is their meaning. The problem is everything that is not text: icons, custom-drawn charts, avatar images, gesture layers. That is where you must provide the description yourself.
Describing Elements in Flutter with Semantics
Flutter generates a semantics tree for you from standard widgets. Text contributes its string, Button and IconButton contribute a button role, and Image contributes its semanticLabel. When you build custom widgets, wrap them in the Semantics widget.
Semantics(
label: 'Search articles',
button: true,
onTap: () => onSearchPressed(),
child: const Icon(Icons.search),
)
The button: true flag gives TalkBack/VoiceOver the role, so they announce “Search articles, button”, and onTap wires the tap action so a screen reader user can trigger it with a double-tap. For text that contains abbreviations, use Text’s semanticsLabel:
const Text(
'Nov 18, 2026',
semanticsLabel: 'November eighteenth, two thousand twenty-six',
)
Icons already accept a label directly:
Icon(Icons.favorite_border, semanticLabel: 'Add to favorites'),
Grouping and Excluding Semantics
Decorative icons that repeat a parent label should be excluded, and composite cards should be read as a single unit:
MergeSemantics(
child: ListTile(
leading: Icon(Icons.thumb_up, semanticLabel: ''),
title: Text('Liked by 1,204 people'),
onTap: openComments,
),
)
MergeSemantics collapses the tile into one focusable node, so TalkBack reads “Liked by 1,204 people” in a single swipe instead of three. Use ExcludeSemantics for purely decorative shapes:
ExcludeSemantics(child: Container(color: gradient.topColor)),
Debugging the Semantics Tree
Flutter ships with a visualizer. Set showSemanticsDebugger: true on your MaterialApp and each widget gets an overlay showing exactly what TalkBack and VoiceOver will hear.
MaterialApp(
showSemanticsDebugger: true,
home: const HomeScreen(),
)
Describing Elements on Android
XML Views
Add android:contentDescription to any view that conveys meaning graphically. Use string resources so the label localizes with the rest of your app.
<ImageView
android:id="@+id/btn_settings"
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_settings"
android:contentDescription="@string/settings" />
Do not write “Settings button” — TalkBack already announces the role. For editable fields use android:hint, not contentDescription. For decorative images, set android:contentDescription="@null" or android:importantForAccessibility="no" so TalkBack skips them entirely. When a label changes at runtime, update it programmatically:
val playPause = findViewById<ImageButton>(R.id.btn_play_pause)
playPause.contentDescription = if (isPlaying) "Pause" else "Play"
Jetpack Compose
Compose’s Image and Icon take a contentDescription parameter, and null marks an element as decorative:
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search articles",
)
For fully custom elements, use the semantics modifier:
Canvas(Modifier.size(48.dp).semantics {
contentDescription = "Signal strength: full"
role = Role.Image
}.clickable { openNetworkSettings() })
Describing Elements on iOS
SwiftUI
Use .accessibilityLabel for views that have no readable text, and let SwiftUI infer the trait (role) from the control type. Avoid restating the trait in the label.
Button(action: playTrack) {
Image(systemName: "play.fill")
}
.accessibilityLabel("Play")
.accessibilityAddTraits(.isButton)
accessibilityAddTraits is useful when a Text or Image acts like a control. For a single-focus composite element, combine children:
HStack {
UnreadIndicatorView(isUnread: message.isUnread)
MessageContentsView(message: message)
Spacer()
Button(action: reply) { Image(systemName: "arrowshape.turn.up.left") }
.accessibilityLabel("Reply")
}
.accessibilityElement(children: .combine)
VoiceOver now reads the whole row as one element with the reply button exposed as a custom action.
UIKit
Set accessibilityLabel and accessibilityTraits on any NSObject conforming to UIAccessibility:
let playButton = UIButton(type: .system)
playButton.setImage(UIImage(systemName: "play.fill"), for: .normal)
playButton.accessibilityLabel = "Play"
playButton.accessibilityTraits = .button
Large Text: Let the User Control the Scale
Never hard-code dp-based or px-based text. Android scales text by font size; iOS by Dynamic Type; Flutter by MediaQuery text scaling. If you use fixed sizes, labels clip and buttons become unusable at 200% text.
Flutter
Default Text scales automatically. Explicit fontSize values also scale, but layout must be flexible — avoid fixed-height containers.
final textScaler = MediaQuery.of(context).textScaler;
Semantics(
header: true,
child: Text(
'Breaking news',
style: Theme.of(context).textTheme.headlineMedium,
),
)
Expanded(
child: Text(
body,
style: const TextStyle(fontSize: 16),
textScaler: textScaler.clamp(maxScaleFactor: 3.0),
),
)
clamp prevents absurdly large scaling from breaking a layout while still honoring the user’s preference. Test with WidgetsBinding.instance.platformDispatcher.textScaleFactorTestValue = 2.0.
Android
Use sp units so the system font scale applies, and let views wrap content:
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:text="@string/article_body" />
Add android:maxLines and android:ellipsize only where collapsing is acceptable. Test in Settings > Display > Font size at 200%, and enable Largest font size in accessibility settings to catch layout breakage.
iOS
UIKit labels auto-scale when adjustsFontForContentSizeCategory is true and the font is a dynamic text style:
label.font = UIFont.preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
label.numberOfLines = 0
In SwiftUI, use the .font(.body) style rather than a fixed size, and react to accessibility sizes with @Environment(\.dynamicTypeSize):
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
let layout = dynamicTypeSize.isAccessibilitySize
? AnyLayout(VStackLayout())
: AnyLayout(HStackLayout())
layout {
FigureImage(...)
FigureTitle(...)
}
}
For tab bars and toolbars that cannot grow, expose the Large Content Viewer:
Button(action: toggleFavorite) { Image(systemName: "star") }
.accessibilityLabel("Toggle favorite")
.accessibilityShowsLargeContentViewer {
Label("Toggle favorite", systemImage: "star")
}
Touch Targets: The 48dp Minimum
Material guidelines recommend every interactive element have at least 48dp x 48dp of tappable area (about 9mm). The visual glyph can be smaller as long as padding extends the hit area.
Android Views
Ensure minWidth, minHeight, and padding sum to at least 48dp per axis:
<ImageButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="48dp"
android:minHeight="48dp"
android:padding="12dp"
android:src="@drawable/ic_close"
android:contentDescription="@string/close" />
In Compose, Material buttons enforce the minimum automatically; for custom clickables, expand the touch area with sizeIn:
Box(
Modifier
.sizeIn(minWidth = 48.dp, minHeight = 48.dp)
.semantics { contentDescription = "Remove" }
.clickable { removeItem() },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Close, contentDescription = null)
}
iOS
Apply the recommended 44pt minimum (Apple HIG) to custom controls, and verify with the Accessibility Inspector’s audit:
Button(action: delete) { Image(systemName: "trash") }
.frame(minWidth: 44, minHeight: 44)
.accessibilityLabel("Delete")
Flutter
Wrap small hit areas in a tappable container that meets 48dp:
Semantics(
label: 'Close',
button: true,
child: InkWell(
onTap: onClose,
customBorder: const CircleBorder(),
child: SizedBox(
width: 48,
height: 48,
child: const Icon(Icons.close),
),
),
)
Testing with TalkBack and VoiceOver
TalkBack (Android): enable in Settings > Accessibility > TalkBack, then swipe right to move focus and double-tap to activate. Listen for “unlabeled” to find missing contentDescription. Run Accessibility Scanner from Play Store for on-device checks, and enable accessibility assertions in Espresso:
AccessibilityChecks.enable()
VoiceOver (iOS): enable via Settings > Accessibility > VoiceOver or by pressing the home/power buttons together. Swipe right to move the focus ring, double-tap to activate, and use the rotor to navigate by heading or element. Use Xcode’s Accessibility Inspector and the debugger’s accessibility audit to catch clipped text and missing labels.
Flutter: run the semantics debugger (showSemanticsDebugger: true), then drive integration tests against the semantics tree:
await tester.tap(find.bySemanticsLabel('Search articles'));
expect(find.bySemanticsLabel('Liked by 1,204 people'), findsOneWidget);
Putting It All Together
A complete accessible flow ties labels, roles, scaling, and touch targets into one coherent unit. Build a favorite button once per platform:
Flutter
Semantics(
label: 'Toggle favorite',
button: true,
toggled: isFavorite,
onTap: toggleFavorite,
child: InkWell(
onTap: toggleFavorite,
child: SizedBox(
width: 48,
height: 48,
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
semanticLabel: '',
),
),
),
)
Compose
IconToggleButton(
checked = isFavorite,
onCheckedChange = { toggleFavorite() },
enabled = true,
) {
Icon(
if (isFavorite) Icons.Favorite else Icons.FavoriteBorder,
contentDescription = null,
)
}
SwiftUI
Button(action: toggleFavorite) {
Image(systemName: isFavorite ? "heart.fill" : "heart")
}
.frame(minWidth: 44, minHeight: 44)
.accessibilityLabel(isFavorite ? "Remove from favorites" : "Add to favorites")
.accessibilityAddTraits(.isButton)
.accessibilityShowsLargeContentViewer {
Label("Toggle favorite", systemImage: "heart")
}
Notice the pattern: a dynamic label that reflects state, a role/trait that says “this is a button”, no redundant visual text in the label, and a hit area that meets the platform minimum.
Conclusion & Next Steps
Semantic labeling, scalable text, and touch target size are the three highest-impact accessibility investments you can make. They are cheap to implement, have no runtime cost, and they unlock your app for TalkBack and VoiceOver users while improving layout robustness for everyone.
Next, run the accessibility audit tools on every platform, fix the reported “unlabeled” elements and small touch targets, and test with the largest font size available. Then go deeper: the Android accessibility service API (AccessibilityNodeInfo) if you build assistive tools, SemanticsNode and CustomSemanticsAction in Flutter for custom gestures, and UIAccessibilityCustomAction plus the adjustable trait on iOS for sliders and pickers.