On this page 8 sections
Why it’s worth doing properly
People use the accessibility features on their iPhones far more widely than “accessibility” suggests. Larger text, bold text, Reduce Motion and VoiceOver are used by people with permanent disabilities, by people with temporary ones such as a broken wrist, and by people in situations that limit them, such as bright sunlight or holding a child in one arm.
There are also concrete business reasons:
- Legal duties. In the UK, public sector bodies have specific accessibility requirements for their websites and mobile apps under the Public Sector Bodies Accessibility Regulations 2018, and service providers more broadly have duties under the Equality Act 2010. If you sell in the EU, the European Accessibility Act has applied to many consumer services, including e-commerce and banking, since June 2025. Take legal advice for your own situation; the point here is that “we’ll do it later” is no longer a safe plan for many apps.
- App Store visibility. Apple added Accessibility Nutrition Labels to App Store product pages, where developers state which features their app supports, such as VoiceOver, larger text and sufficient contrast. See Overview of Accessibility Nutrition Labels. You can only claim a feature honestly if common tasks in your app work with it.
- Better apps for everyone. Clear labels, layouts that cope with large text and good contrast make an app easier for all users.
Apple’s Human Interface Guidelines on accessibility are the design reference.
VoiceOver: labels, values, traits and grouping
VoiceOver reads each accessibility element aloud: its label (what it is), value (its current state), traits (button, header, selected) and hint (what happens if you activate it). SwiftUI fills these in for standard controls. Custom views need help.
Images and icon buttons need a label. A button with only an SF Symbol gets a guessed label from the symbol name, which is often wrong:
Button {
toggleFavourite()
} label: {
Image(systemName: isFavourite ? "heart.fill" : "heart")
}
.accessibilityLabel(isFavourite ? "Remove from favourites" : "Add to favourites")Purely decorative images should be hidden with Image(decorative:) or .accessibilityHidden(true), so VoiceOver users don’t have to swipe past them.
Rows made of several pieces should usually read as one element:
struct OrderRow: View {
let order: Order
let onTrack: () -> Void
var body: some View {
HStack {
Image(systemName: order.status.symbol)
.accessibilityHidden(true)
VStack(alignment: .leading) {
Text("Order \(order.number)")
Text(order.status.label).foregroundStyle(.secondary)
}
Spacer()
Text(order.total, format: .currency(code: "GBP"))
}
.accessibilityElement(children: .combine)
.accessibilityAction(named: "Track parcel", onTrack)
}
}.combine merges the texts into one element, so VoiceOver reads “Order 1042, Dispatched, £24.00” in a single swipe instead of four. The custom action puts “Track parcel” in VoiceOver’s actions rotor, so the row doesn’t need a tiny extra button.
Headings let VoiceOver users jump between sections with the rotor. Mark section titles that aren’t already headers:
Text("Upcoming")
.font(.title2.bold())
.accessibilityAddTraits(.isHeader)Custom controls should present themselves as the standard control they behave like. accessibilityRepresentation lets a hand-drawn slider be read and adjusted exactly like a system Slider:
struct RatingDial: View {
@Binding var rating: Double
var body: some View {
DialShape(value: rating)
.gesture(dragGesture)
.accessibilityRepresentation {
Slider(value: $rating, in: 0...5, step: 1) {
Text("Rating")
}
}
}
}Dynamic Type: let the layout breathe
Dynamic Type lets people choose their text size, and the largest accessibility sizes are very large. Three habits handle nearly all of it:
- Use text styles, such as
.body,.headlineand.caption, rather than fixed point sizes. For a custom font,.font(.custom("Brand", size: 17, relativeTo: .body))still scales. - Scale non-text sizes with
@ScaledMetric, so icons and spacing grow with the text:
struct StatTile: View {
let title: String
let value: String
@ScaledMetric(relativeTo: .body) private var iconSize = 24.0
var body: some View {
HStack {
Image(systemName: "chart.bar.fill")
.resizable()
.scaledToFit()
.frame(width: iconSize, height: iconSize)
.accessibilityHidden(true)
VStack(alignment: .leading) {
Text(title).font(.subheadline)
Text(value).font(.title3.bold())
}
}
}
}- Switch layouts at accessibility sizes. Side-by-side content that fits at the default size often gets squashed at the largest ones. Stack it instead:
struct ProductHeader: View {
@Environment(\.dynamicTypeSize) private var typeSize
let product: Product
var body: some View {
let layout = typeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading, spacing: 12))
: AnyLayout(HStackLayout(alignment: .top, spacing: 16))
layout {
ProductImage(product: product)
ProductSummary(product: product)
}
}
}ViewThatFits is an alternative when you’d rather SwiftUI pick the first layout that fits the space. Avoid .lineLimit(1) on anything important; let text wrap.
Colour, contrast and motion
- Contrast. Body text needs strong contrast against its background in both light and dark mode. The WCAG contrast ratios are a sensible benchmark, and the Accessibility Inspector in Xcode can check colours on screen.
- Don’t rely on colour alone. A red border on a form field should come with an error message or an icon. The same goes for charts: use labels or patterns as well as colours.
- Respect system settings. SwiftUI’s environment exposes the user’s choices:
accessibilityReduceMotion,accessibilityReduceTransparency,colorSchemeContrastandaccessibilityDifferentiateWithoutColor.
Reduce Motion is the one most often ignored. Large zooms, parallax and spinning transitions can make some people feel unwell:
struct ExpandingCard: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isExpanded = false
var body: some View {
CardContent(isExpanded: isExpanded)
.onTapGesture { isExpanded.toggle() }
.animation(reduceMotion ? .easeInOut(duration: 0.15) : .spring(duration: 0.45), value: isExpanded)
}
}The reduced version still animates, but with a short fade-like change rather than movement and bounce.
Testing: by hand and automatically
Use the features yourself. Turn on VoiceOver (Settings › Accessibility › VoiceOver, or set the Accessibility Shortcut to triple-click the side button) and complete your app’s three most important tasks with the screen curtain on. Then set text to the largest accessibility size and do the same. Twenty minutes of this finds more than any checklist.
Accessibility Inspector, in Xcode’s Developer Tools menu, shows each element’s label, value and traits, and its audit finds missing labels, small hit areas, clipped text and contrast problems.
Automated audits in UI tests catch regressions. Since Xcode 15, XCUIApplication can run the same audit:
import XCTest
final class AccessibilityAuditTests: XCTestCase {
@MainActor
func testHomeScreenPassesAudit() throws {
let app = XCUIApplication()
app.launch()
try app.performAccessibilityAudit()
}
@MainActor
func testCheckoutPassesAuditIgnoringKnownIssue() throws {
let app = XCUIApplication()
app.launch()
app.buttons["Basket"].tap()
try app.performAccessibilityAudit(for: [.dynamicType, .contrast, .hitRegion, .sufficientElementDescription]) { issue in
// Return true to ignore an issue you've reviewed and accepted.
issue.element?.identifier == "promo-banner"
}
}
}The automated audit won’t tell you whether a flow makes sense when read aloud, so it complements manual testing rather than replacing it.
A checklist for every new screen
- Every interactive element has a meaningful label; decorative images are hidden.
- Related text is grouped so VoiceOver reads a row once, with custom actions for secondary buttons.
- Section titles have the header trait.
- The screen works at the largest accessibility text size without clipping or overlapping.
- Tap targets are comfortably large; Apple’s guidelines recommend at least 44 by 44 points.
- Information isn’t conveyed by colour alone, and contrast holds in light and dark mode.
- Animations respect Reduce Motion.
- The UI test for the screen calls
performAccessibilityAudit().
The Apple Watch has its own constraints; Designing for the Apple Watch covers accessibility on the wrist.
Voice Control, focus and announcements
VoiceOver isn’t the only assistive technology that reads your labels. Voice Control lets people operate the phone by speaking the names of buttons, so a label also has to be something people would naturally say. When several phrases are reasonable, list them:
Button {
compose()
} label: {
Image(systemName: "square.and.pencil")
}
.accessibilityLabel("New message")
.accessibilityInputLabels(["New message", "Compose", "Write"])Focus matters when something appears that the user needs to know about, such as a validation error after tapping “Save”. Sighted users see the red text; VoiceOver users hear nothing unless you move focus to it:
struct SignUpForm: View {
@State private var email = ""
@State private var errorMessage: String?
@AccessibilityFocusState private var errorFocused: Bool
var body: some View {
Form {
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
if let errorMessage {
Text(errorMessage)
.foregroundStyle(.red)
.accessibilityFocused($errorFocused)
}
Button("Create account", action: submit)
}
}
private func submit() {
guard email.contains("@") else {
errorMessage = "Enter an email address, like name@example.com."
errorFocused = true
return
}
errorMessage = nil
}
}For changes that don’t need focus, such as “Saved” after an autosave, post an announcement instead: AccessibilityNotification.Announcement("Saved").post(). Use announcements sparingly; a running commentary is as tiring to listen to as it would be to read.
Switch Control and Full Keyboard Access move through the same accessibility elements in order. If VoiceOver’s order through a screen makes sense and every action is reachable as a button or a custom action, these usually work too. Test them once on each key screen.
Key takeaways
- SwiftUI’s standard controls are accessible by default; the work is in custom views, icons and grouped rows.
- Use labels, traits,
.combineand custom actions to make VoiceOver efficient, not just possible. - Use text styles and
@ScaledMetric, and switch layouts at accessibility sizes. - Respect Reduce Motion and contrast settings, and never rely on colour alone.
- Test by hand with VoiceOver and large text, and add
performAccessibilityAudit()to UI tests.
If you need an accessibility review of an existing app, or want to make sure a new one can honestly display Accessibility Nutrition Labels at launch, get in touch.
Written by Lee Johnson. Published 30 October 2026.