SwiftUI gives you a lot of accessibility for free, and then takes it away the moment you draw a control yourself. This lesson is what the framework supplies, what it cannot, and the handful of modifiers that cover almost everything — checked against the real controls in the demo app that each needed a fix.
What you get, and what you lose
A Button with a text label is announced as "Add to cart, button" with no work. A
Toggle reports its state. A TextField with a label is a text field.
Now draw a radio button out of two circles and a tap gesture. VoiceOver sees a shape. It does not know it is a control, does not know it can be activated, and certainly does not know whether it is selected.
That is the rule underneath everything below: a drawn control carries no meaning. The fix is small, and skipping it makes your app unusable for people who cannot see the fill colour.
Why this is worth an afternoon
Two arguments, and the second one is the one that tends to land in a planning meeting.
The first is that roughly one in seven people has a disability, and a meaningful fraction of your customers use at least one of the features below — most commonly larger text, which is not what people picture when they hear "accessibility".
The second is that almost everything here is also a correctness improvement for everybody. A 44-point touch target is easier for anyone to hit. A button that is really a button responds to the hardware keyboard, to Switch Control, and to whatever Apple ships next. A label that describes what a control does is the same information a UI test needs to find it.
Label, value, trait
Three different things, constantly confused.
Label is what it is — "Open cart". Value is its current state — "3 items". Traits are what kind of thing it is — a button, a header, a toggle, selected.
.accessibilityLabel(
showsBadge
? "Open cart, \(count) \(count == 1 ? "item" : "items")"
: "Open cart, empty"
)
Without that, the cart button announces as "cart" — the SF Symbol's name — and the badge, which is the entire information content, is invisible. Note that the label pluralises: "1 items" is the kind of detail that is obvious in text and grating when read aloud.
Toggles that are not Toggles
.buttonStyle(.plain)
.accessibilityAddTraits(.isToggle)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
.accessibilityLabel(detail.map { "\(label), \($0)" } ?? label)
That is a topping chip. Without the traits, VoiceOver announces "Pepperoni, button" and never says whether it is on. With them: "Pepperoni, plus $1.75, selected, toggle button" — which is the whole control.
The same pattern applies to the segmented picker. Without it a customer using VoiceOver cannot tell whether their order is delivery or pickup, which is not a cosmetic failure.
Note it is a Button with .plain rather than a tappable
HStack. A tap gesture carries no semantics at all — no button trait, no press feedback,
no Switch Control support. .plain strips the styling and keeps all of it.
The whole picker, for comparison
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.sm)
.padding(.horizontal, Spacing.sm)
.background(
RoundedRectangle(cornerRadius: Radius.sm)
.fill(isSelected ? Theme.colors.primary : .clear)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityAddTraits(.isToggle)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
.accessibilityLabel(segment.subtitle.map { "\(segment.label), \($0)" } ?? segment.label)
Four modifiers of accessibility against eight of layout, which is roughly the ratio to expect. The subtitle is folded into the label rather than left as a separate element, so "Delivery, $3.99 fee, selected" comes out as one announcement instead of three — and the customer learns the fee at the moment they are choosing, rather than after.
Drawn radios
private func radio(isSelected: Bool) -> some View {
ZStack {
Circle()
.strokeBorder(
isSelected ? Theme.colors.primary : Theme.colors.border,
lineWidth: 2
)
.frame(width: 20, height: 20)
if isSelected {
Circle().fill(Theme.colors.primary).frame(width: 10, height: 10)
}
}
.padding(.top, 2)
.accessibilityHidden(true)
}
.accessibilityHidden(true) on the circles, and the traits on the row that contains
them. The dot is decoration — the row is the control, and announcing both means a customer
swipes through twice as many elements to reach the same information.
Hiding decoration is as important as labelling controls. Every emoji in the demo app's empty states is hidden for the same reason: "pizza slice" read aloud before "No orders yet" is noise.
Combining a row into one announcement
.accessibilityElement(children: .combine)
On a price row, that turns two stops — "Total", then "$24.16" — into one: "Total, $24.16". Applied to an order card it turns four fragments into a sentence.
The trap that comes with it
Combining children hides them from the accessibility tree. So a button nested inside a combined container becomes unreachable — and it fails silently, because the screen still looks right.
VStack(alignment: .leading, spacing: Spacing.xs) {
Text("Something went wrong").textStyle(.bodyStrong, tone: .danger)
Text(message).textStyle(.caption, tone: .muted)
}
.accessibilityElement(children: .combine)
if let retry {
Button("Try again", action: retry)
The two text lines are combined; the retry button is deliberately outside that group. Getting this backwards means the error is announced beautifully and the only way to recover from it cannot be reached — which makes the screen worse for exactly the people the effort was for.
A spinner is invisible
VStack(spacing: Spacing.sm) {
ProgressView()
.progressViewStyle(.circular)
.tint(Theme.colors.primary)
Text(label).textStyle(.caption, tone: .muted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.xxxl)
.accessibilityElement(children: .combine)
.accessibilityLabel(label)
A ProgressView on its own announces nothing useful, so a customer using VoiceOver on
a loading screen hears silence and has no way to tell whether the app is working or stuck. The
visible label doubles as the accessible one — which is the general answer, and better than an
invisible accessibility-only string that can drift out of sync with what is on screen.
Announcing things that appear on their own
A toast appears without the customer doing anything, so nothing focuses it and nothing reads it. A confirmation nobody hears is a confirmation that did not happen.
.accessibilityAddTraits(.isStaticText)
.accessibilityLabel(toast.message)
That much makes it reachable. For something the customer must not miss, the stronger option is to post an announcement notification so VoiceOver reads it immediately rather than waiting to be swiped to. The general rule: if information appears without an interaction, ask how somebody not looking at the screen finds out.
Headings, so a screen can be skimmed
Text(title)
.textStyle(.heading)
.accessibilityAddTraits(.isHeader)
Sighted readers skim by looking at the big text. VoiceOver users skim with the rotor set to Headings — but only if something claims to be one. Marking a sheet's title and each section heading turns a long screen from a linear swipe into something navigable, and it is one modifier per heading.
Touch targets
.frame(minHeight: 44)
Apple asks for 44 points. A small chip or an icon button is usually visually smaller, and the
answer is to grow the tappable frame rather than the design — minHeight on the
button style, or a 44-point frame around a glyph.
With one modifier that people miss:
.frame(width: 44, height: 44)
.contentShape(Rectangle())
Without .contentShape, only the drawn glyph is hit-testable, so the 44-point frame is
a lie — it looks like a large target and behaves like a small one. Worth reaching for any time a
tappable area is mostly empty space.
Dynamic Type
A meaningful number of people run their phone at larger text sizes, and the demo app is honest
about being the weakest here: it uses .system(size:), which pins a point size and does
not scale.
The alternatives are the semantic styles — .system(.body) and friends — or
@ScaledMetric for a custom size that scales with them. If you are building something
real, start there. Retrofitting it later means revisiting every fixed frame that was sized around a
fixed font, which is most of them.
Custom actions, when a row does several things
An address row in the profile screen has three buttons — make primary, edit, delete. Swiping through all of them for every address is tedious, and it is the usual reason VoiceOver users abandon a list screen.
.accessibilityAction(named:) collapses them into the rotor: one stop for the row,
with the actions available on a flick. The demo app does not do this yet, and it is the clearest
remaining gap in its accessibility — worth knowing because the pattern is small and the improvement
is not.
Checking it, in about a minute
Three things, in increasing order of effort.
The Accessibility Inspector (Xcode → Open Developer Tool) points at your running app and reads out what each element exposes. Its audit button catches missing labels, contrast failures and small hit targets in one pass.
VoiceOver on a real device — triple-click the side button to toggle it. Swipe through one screen. You will find something in the first thirty seconds, every time.
Dynamic Type at the largest accessibility size, either in Settings or with the environment override in a preview. Truncation and overlap show up immediately.
Other people using your app differently
VoiceOver is the one everybody thinks of, and it is not the only thing to check.
Reduce Motion — some people get motion sickness from large transitions. Read
@Environment(\.accessibilityReduceMotion) and swap an animated transition for a
cross-fade.
Colour alone is not information. The order badges in this app carry a word as well as a colour, which is what makes them work for somebody who cannot distinguish the two. The same applies to a form field whose only error signal is a red border.
Contrast. 4.5:1 for body text is the WCAG threshold and the Accessibility Inspector's audit checks it — muted grey on a tinted background is the pairing that usually fails.
Switch Control and keyboard navigation both rely on things being real controls rather than tap gestures, which is the same fix as everything above.
What is worth doing first
If you only do four things:
- Label every icon-only control.
- Add
.isToggleand.isSelectedto anything you drew that has a state. - Hide decoration with
.accessibilityHidden(true). - Make sure every tappable thing is 44 points, and that the frame is really hit-testable.
That is an afternoon, and it is the difference between an app somebody can use and one they cannot. The rest — combined rows, announcements, custom actions — is refinement on top, and it is much easier to add once the four basics are in place than to retrofit all of it at once before a deadline.
Next
Everything in this track has claimed to be testable. The next lesson proves it: what to test, what to skip, and how to test async and main-actor code without a running backend.