Why Accessibility Matters More Than You Think
According to the World Health Organization, over 1.3 billion people worldwide live with some form of disability — roughly 16% of the global population. In the US alone, the CDC reports that 1 in 4 adults has a disability that impacts daily activity, and 12 million people over age 40 have some form of vision impairment.
But accessibility goes beyond permanent disabilities. A parent holding a baby while using your app one-handed, a commuter squinting at low-contrast text in sunlight, or a user with a temporary hand injury relying on voice commands — these are all accessibility scenarios. When you build for accessibility, you build for everyone.
- Apple App Store requirements. Apple's HIG explicitly requires accessibility support. Apps that are not accessible to VoiceOver users risk rejection, especially as Apple tightens enforcement.
- Legal compliance. The ADA has been applied to mobile apps in multiple US court rulings. The European Accessibility Act (EAA), effective June 2025, requires digital products sold in the EU to meet WCAG 2.1 AA. Non-compliance can result in fines up to 5% of annual turnover.
- Market opportunity. The spending power of people with disabilities in the US exceeds $490 billion annually. Building accessible apps is not charity — it is smart business.
Accessibility is not a feature. It is a fundamental quality of software. If your app cannot be used by everyone, it is incomplete.
VoiceOver Fundamentals in SwiftUI
VoiceOver is Apple's built-in screen reader. It reads aloud what is on screen and lets users navigate by swiping left and right through elements. SwiftUI provides excellent VoiceOver support out of the box — Text, Button, Toggle, and other standard components are automatically exposed to the accessibility system. But custom views need manual configuration.
To test VoiceOver, use Cmd + F5 in the Simulator or triple-click the side button on a device. When VoiceOver lands on an element, it announces up to four things:
- Label — what the element is ("Submit button", "Profile image")
- Value — the current state ("50%", "On")
- Trait — the element type ("button", "heading", "adjustable")
- Hint — what happens if you interact ("Double tap to submit")
Accessibility Labels, Values, and Hints
Labels and Values
The label is the most important accessibility property. It tells VoiceOver what the element is. Every interactive element and meaningful image must have a clear label. Values communicate dynamic state for sliders, ratings, and custom controls:
// Icon-only button — needs a label
Button(action: { showSettings = true }) {
Image(systemName: "gearshape")
}
.accessibilityLabel("Settings")
// Dynamic label reflecting state
Image(systemName: isFavorited ? "heart.fill" : "heart")
.accessibilityLabel(isFavorited ? "Remove from favorites" : "Add to favorites")
// Custom rating control with value
struct StarRating: View {
@Binding var rating: Int
var body: some View {
HStack(spacing: 4) {
ForEach(1...5, id: \.self) { star in
Image(systemName: star <= rating ? "star.fill" : "star")
.foregroundStyle(star <= rating ? .yellow : .gray)
.onTapGesture { rating = star }
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Rating")
.accessibilityValue("\(rating) out of 5 stars")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: rating = min(5, rating + 1)
case .decrement: rating = max(1, rating - 1)
@unknown default: break
}
}
}
}Hints
Hints describe the result of interacting with an element. Use them sparingly — only when the action is not obvious from the label:
Button("Delete Account") {
showDeleteConfirmation = true
}
.accessibilityHint("Shows a confirmation dialog before deleting your account")
// Do NOT add obvious hints like:
// .accessibilityHint("Double tap to activate") — VoiceOver says this alreadyAccessibility Traits
Traits tell VoiceOver how an element behaves. SwiftUI applies defaults to standard views, but custom views need manual assignment:
// Mark as heading for rotor navigation
Text("Account Settings")
.font(.title2.bold())
.accessibilityAddTraits(.isHeader)
// Custom toggle-like control
HStack {
Text("Notifications")
Spacer()
Circle().fill(isEnabled ? Color.green : Color.gray).frame(width: 28, height: 28)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Notifications")
.accessibilityValue(isEnabled ? "On" : "Off")
.accessibilityAddTraits(.isButton)
.accessibilityAction { isEnabled.toggle() }
// Purely decorative image — hide from VoiceOver
Image("decorative-pattern")
.accessibilityHidden(true)SwiftUI Accessibility Modifiers Reference
Bookmark this comprehensive reference of every accessibility modifier in SwiftUI:
| Modifier | Purpose | Example |
|---|---|---|
.accessibilityLabel(_:) | Sets the VoiceOver label | .accessibilityLabel("Close") |
.accessibilityValue(_:) | Communicates dynamic state | .accessibilityValue("50%") |
.accessibilityHint(_:) | Describes the result of interaction | .accessibilityHint("Opens settings") |
.accessibilityAddTraits(_:) | Adds traits (button, header, image) | .accessibilityAddTraits(.isHeader) |
.accessibilityRemoveTraits(_:) | Removes default traits | .accessibilityRemoveTraits(.isButton) |
.accessibilityHidden(_:) | Hides element from VoiceOver | .accessibilityHidden(true) |
.accessibilityElement(children:) | Controls child grouping | .accessibilityElement(children: .combine) |
.accessibilityAction(_:) | Adds a custom action | .accessibilityAction { doSomething() } |
.accessibilityAdjustableAction(_:) | Makes element swipe-adjustable | .accessibilityAdjustableAction { dir in ... } |
.accessibilityInputLabels(_:) | Alternative Voice Control labels | .accessibilityInputLabels(["Close", "Dismiss"]) |
.accessibilitySortPriority(_:) | Controls reading order | .accessibilitySortPriority(1) |
.accessibilityIdentifier(_:) | Sets identifier for UI testing | .accessibilityIdentifier("loginBtn") |
.accessibilityRotor(_:entries:) | Custom rotor navigation | .accessibilityRotor("Chapters") { ... } |
.accessibilityFocused(_:) | Programmatic VoiceOver focus | .accessibilityFocused($isFocused) |
Custom Accessibility Actions
When you build custom gestures — long press, drag, multi-finger taps — you must provide accessible alternatives. VoiceOver users cannot perform complex gestures:
struct TaskCard: View {
let task: Task
let onComplete: () -> Void
let onDelete: () -> Void
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(task.title).font(.headline)
Text(task.dueDate, style: .date).font(.caption).foregroundStyle(.secondary)
}
Spacer()
if task.isCompleted {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
}
}
.padding()
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.accessibilityElement(children: .combine)
.accessibilityLabel("\(task.title), due \(task.dueDate.formatted())")
.accessibilityValue(task.isCompleted ? "Completed" : "Pending")
.accessibilityAction(named: "Mark Complete") { onComplete() }
.accessibilityAction(named: "Delete") { onDelete() }
}
}Dynamic Type with @ScaledMetric
Dynamic Type lets users adjust text size system-wide. SwiftUI respects it automatically for system fonts, but custom spacing, icon sizes, and padding need manual scaling with @ScaledMetric:
struct ProfileHeader: View {
let user: User
@ScaledMetric(relativeTo: .body) private var avatarSize: CGFloat = 64
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 20
@ScaledMetric(relativeTo: .body) private var spacing: CGFloat = 16
var body: some View {
HStack(spacing: spacing) {
AsyncImage(url: user.avatarURL) { image in
image.resizable()
} placeholder: {
Circle().fill(.quaternary)
}
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text(user.name).font(.headline)
HStack(spacing: 6) {
Image(systemName: "envelope")
.frame(width: iconSize, height: iconSize)
.accessibilityHidden(true)
Text(user.email).font(.subheadline).foregroundStyle(.secondary)
}
}
}
}
}Dynamic Type Size Reference
Here is how iOS Dynamic Type sizes map to actual point sizes for the .body text style:
| Dynamic Type Size | Body Font (pt) | Scale Factor | Category |
|---|---|---|---|
| xSmall | 14 pt | 0.82x | Standard |
| Small | 15 pt | 0.88x | Standard |
| Medium | 16 pt | 0.94x | Standard |
| Large (Default) | 17 pt | 1.00x | Standard |
| xLarge | 19 pt | 1.12x | Standard |
| xxLarge | 21 pt | 1.24x | Standard |
| xxxLarge | 23 pt | 1.35x | Standard |
| AX1 | 28 pt | 1.65x | Accessibility |
| AX2 | 33 pt | 1.94x | Accessibility |
| AX3 | 40 pt | 2.35x | Accessibility |
| AX4 | 47 pt | 2.76x | Accessibility |
| AX5 | 53 pt | 3.12x | Accessibility |
At AX5, body text is over 3x the default size. Your layouts must accommodate this. Use ScrollView for content that might overflow, avoid fixed heights on text containers, and test at AX5 before every release.
Reduce Motion with @Environment
Some users experience motion sickness, vertigo, or seizures from screen animations. SwiftUI exposes the "Reduce Motion" setting via the environment — you must respect it:
struct AnimatedCard: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isVisible = false
var body: some View {
VStack {
Text("Welcome Back").font(.title.bold())
Text("Your progress has been saved").foregroundStyle(.secondary)
}
.padding(24)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
.opacity(isVisible ? 1 : 0)
.offset(y: reduceMotion ? 0 : (isVisible ? 0 : 20))
.animation(reduceMotion ? .none : .spring(response: 0.5, dampingFraction: 0.8), value: isVisible)
.onAppear { isVisible = true }
}
}The pattern: when Reduce Motion is enabled, replace sliding and bouncing with simple fades or remove animation entirely. Never ignore this setting — it is a medical necessity for some users.
Reduce Transparency and Increase Contrast
iOS provides Reduce Transparency and Increase Contrast settings for users who struggle with translucent backgrounds and subtle color differences. SwiftUI exposes both:
struct AdaptiveCard: View {
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@Environment(\.colorSchemeContrast) private var contrast
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Subscription Active").font(.headline)
Text("Your plan renews on April 15, 2026")
.font(.subheadline)
.foregroundStyle(contrast == .increased ? .primary : .secondary)
}
.padding(16)
.background(
reduceTransparency
? AnyShapeStyle(Color(.systemBackground))
: AnyShapeStyle(.ultraThinMaterial)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(contrast == .increased ? Color.primary.opacity(0.5) : Color.clear, lineWidth: 1)
)
}
}Accessibility Containers and Grouping
A common VoiceOver problem is over-fragmented navigation. If a card has a title, subtitle, timestamp, and icon, VoiceOver stops on each one — forcing the user to swipe four times past a single card. The fix is grouping:
struct ArticleCard: View {
let article: Article
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(article.category).font(.caption.bold()).foregroundStyle(.accent)
Spacer()
Text(article.readTime).font(.caption).foregroundStyle(.secondary)
}
Text(article.title).font(.headline)
Text(article.summary).font(.subheadline).foregroundStyle(.secondary).lineLimit(2)
}
.padding(16)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.accessibilityElement(children: .combine)
.accessibilityLabel("\(article.title). \(article.category). \(article.readTime)")
.accessibilityHint("Double tap to read the full article")
.accessibilityAddTraits(.isButton)
}
}Use children: .combine to merge child elements into a single focus point. Use children: .ignore when you set a completely custom label. Use children: .contain (the default) when each child should remain independently focusable.
Custom Accessibility Rotors
The VoiceOver rotor lets users jump between specific content types — headings, links, form fields. You can add custom rotor entries for domain-specific content:
ScrollView {
LazyVStack(spacing: 8) {
ForEach(messages) { message in
MessageBubble(message: message).id(message.id)
}
}
}
.accessibilityRotor("Messages from others") {
ForEach(messages.filter { !$0.isFromCurrentUser }) { message in
AccessibilityRotorEntry(message.senderName, id: message.id)
}
}
.accessibilityRotor("Attachments") {
ForEach(messages.filter { $0.hasAttachment }) { message in
AccessibilityRotorEntry("\(message.senderName): \(message.attachmentType)", id: message.id)
}
}Accessibility Focus Management
When content changes dynamically, VoiceOver users need to know. Without focus management, they are left stranded on a stale element:
struct CheckoutView: View {
@State private var showConfirmation = false
@AccessibilityFocusState private var isConfirmationFocused: Bool
var body: some View {
VStack(spacing: 20) {
Button("Place Order") {
processOrder()
showConfirmation = true
isConfirmationFocused = true
}
if showConfirmation {
VStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 48)).foregroundStyle(.green)
.accessibilityHidden(true)
Text("Order Confirmed!").font(.title2.bold())
Text("Your order #1234 has been placed successfully.")
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityFocused($isConfirmationFocused)
}
}
}
}
// Transient announcements without moving focus
AccessibilityNotification.Announcement("Item added to cart").post()Practical Example: Accessible Custom Button
Here is a production-quality custom button that handles every accessibility requirement — Dynamic Type, Reduce Motion, VoiceOver labels, and loading states:
struct AccessibleButton: View {
let title: String
let icon: String?
let isLoading: Bool
let action: () -> Void
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 18
@ScaledMetric(relativeTo: .body) private var verticalPad: CGFloat = 14
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
if isLoading {
ProgressView().tint(.white).accessibilityHidden(true)
} else if let icon {
Image(systemName: icon)
.frame(width: iconSize, height: iconSize)
.accessibilityHidden(true)
}
Text(title).font(.body.weight(.semibold))
}
.frame(maxWidth: .infinity)
.padding(.vertical, verticalPad)
.padding(.horizontal, 24)
.background(.accent)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(isLoading)
.accessibilityLabel(isLoading ? "\(title), loading" : title)
.accessibilityRemoveTraits(isLoading ? .isButton : [])
.opacity(isLoading ? 0.7 : 1)
.animation(reduceMotion ? .none : .easeInOut(duration: 0.2), value: isLoading)
}
}Practical Example: Accessible Card Component
Cards need grouped accessibility so VoiceOver reads them as one element instead of stopping on every child:
struct AccessibleProductCard: View {
let product: Product
let onAddToCart: () -> Void
@ScaledMetric(relativeTo: .body) private var imageHeight: CGFloat = 160
var body: some View {
VStack(alignment: .leading, spacing: 0) {
AsyncImage(url: product.imageURL) { phase in
if let image = phase.image { image.resizable().aspectRatio(contentMode: .fill) }
else { Color.gray.opacity(0.2) }
}
.frame(height: imageHeight).clipped().accessibilityHidden(true)
VStack(alignment: .leading, spacing: 8) {
Text(product.name).font(.headline)
Text(product.formattedPrice).font(.subheadline.bold()).foregroundStyle(.accent)
}.padding(16)
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(product.name), \(product.formattedPrice)")
.accessibilityHint("Double tap to view details")
.accessibilityAddTraits(.isButton)
.accessibilityAction(named: "Add to Cart") { onAddToCart() }
}
}Practical Example: Accessible Chart
Charts are inherently visual. Provide a complete textual summary as the accessibility label so screen reader users get equivalent information:
struct AccessibleBarChart: View {
let dataPoints: [ChartDataPoint]
let title: String
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title).font(.headline).accessibilityAddTraits(.isHeader)
HStack(alignment: .bottom, spacing: 6) {
ForEach(dataPoints) { point in
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 4).fill(.accent)
.frame(width: 32, height: barHeight(for: point.value))
Text(point.label).font(.caption2).foregroundStyle(.secondary)
}
}
}.frame(height: 200)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel({
let summary = dataPoints.map { "\($0.label): \($0.formattedValue)" }.joined(separator: ". ")
let highest = dataPoints.max(by: { $0.value < $1.value })
return "\(title). \(dataPoints.count) data points. Highest: \(highest?.label ?? ""). \(summary)"
}())
}
private func barHeight(for value: Double) -> CGFloat {
CGFloat(value / (dataPoints.map(\.value).max() ?? 1)) * 180
}
}Practical Example: Accessible Form
Forms need particular attention because errors must be announced immediately and focus should move to the first error field:
struct AccessibleSignUpForm: View {
@State private var email = ""
@State private var password = ""
@State private var emailError: String?
@State private var passwordError: String?
@AccessibilityFocusState private var focusedField: FormField?
enum FormField: Hashable { case email, password }
var body: some View {
Form {
Section {
VStack(alignment: .leading, spacing: 4) {
TextField("Email address", text: $email)
.textContentType(.emailAddress)
.accessibilityLabel("Email address")
.accessibilityValue(email.isEmpty ? "Empty" : email)
.accessibilityFocused($focusedField, equals: .email)
if let emailError {
Text(emailError).font(.caption).foregroundStyle(.red)
.accessibilityLabel("Error: \(emailError)")
}
}
VStack(alignment: .leading, spacing: 4) {
SecureField("Password", text: $password)
.accessibilityFocused($focusedField, equals: .password)
if let passwordError {
Text(passwordError).font(.caption).foregroundStyle(.red)
.accessibilityLabel("Error: \(passwordError)")
}
}
} header: { Text("Create Account").accessibilityAddTraits(.isHeader) }
Button("Sign Up") {
emailError = email.contains("@") ? nil : "Enter a valid email"
passwordError = password.count >= 8 ? nil : "Min 8 characters"
if emailError != nil || passwordError != nil {
focusedField = emailError != nil ? .email : .password
}
}
}
}
}Color Contrast Requirements
Color contrast is one of the most frequently failed audits. WCAG 2.1 requires 4.5:1 for normal text (AA), 3:1 for large text (18pt or 14pt bold), 7:1 for AAA-level, and 3:1 for non-text UI elements (icons, borders). Test with Xcode's Accessibility Inspector or the Colour Contrast Analyser app. Never use color alone to convey information — approximately 8% of men have color vision deficiency:
// Bad: color-only error indication
TextField("Email", text: $email)
.border(hasError ? Color.red : Color.clear)
// Good: color + icon + text
VStack(alignment: .leading, spacing: 4) {
TextField("Email", text: $email)
.border(hasError ? Color.red : Color.gray.opacity(0.3))
if hasError {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.red).accessibilityHidden(true)
Text("Please enter a valid email address")
.font(.caption).foregroundStyle(.red)
}
.accessibilityElement(children: .combine)
}
}Haptic Feedback for Accessibility
Haptics provide a non-visual, non-auditory channel that benefits all users — especially those with visual or hearing impairments:
// UIImpactFeedbackGenerator — button taps and UI interactions
let impact = UIImpactFeedbackGenerator(style: .medium)
impact.prepare() // Pre-warm for zero latency
impact.impactOccurred()
// UINotificationFeedbackGenerator — success/error/warning outcomes
let notification = UINotificationFeedbackGenerator()
notification.notificationOccurred(.success) // or .error, .warning
// UISelectionFeedbackGenerator — picker and selection changes
UISelectionFeedbackGenerator().selectionChanged()Testing with Accessibility Inspector
Xcode's Accessibility Inspector (Xcode > Open Developer Tool > Accessibility Inspector) has three essential modes: Inspection (hover over elements to see label, value, traits, and frame), Audit (automated checks for missing labels, contrast, and touch targets), and Settings simulation (toggle VoiceOver, Dynamic Type, and Reduce Motion without leaving Xcode). Run the audit on every new screen before merging PRs.
Automated Accessibility Audits
Starting with iOS 17, Apple introduced performAccessibilityAudit for XCTest. Add these to your CI pipeline to catch regressions:
import XCTest
final class AccessibilityAuditTests: XCTestCase {
func testHomeScreenAccessibility() throws {
let app = XCUIApplication()
app.launch()
try app.performAccessibilityAudit()
}
func testAllScreensAccessibility() throws {
let app = XCUIApplication()
app.launch()
for screen in ["Home", "Settings", "Profile", "Search"] {
app.tabBars.buttons[screen].tap()
try app.performAccessibilityAudit(for: [.dynamicType, .sufficientElementDescription, .contrast])
}
}
}Common Accessibility Audit Issues and Fixes
| Issue | Impact | Fix |
|---|---|---|
| Icon-only button without label | VoiceOver reads SF Symbol name | Add .accessibilityLabel("Settings") |
| Decorative image not hidden | VoiceOver reads filename, clutters navigation | Add .accessibilityHidden(true) |
| Card with multiple VoiceOver stops | User swipes 4-5 times past one card | Use .accessibilityElement(children: .combine) |
| Custom control missing traits | VoiceOver does not announce element type | Add .accessibilityAddTraits(.isButton) |
| Text contrast below 4.5:1 | Unreadable for users with low vision | Increase foreground/background contrast ratio |
| Touch target smaller than 44x44pt | Motor-impaired users cannot tap reliably | Add .frame(minWidth: 44, minHeight: 44) |
| Fixed-height text container | Text clips at large Dynamic Type sizes | Use ScrollView or .fixedSize(horizontal: false, vertical: true) |
| Animation ignores Reduce Motion | Can cause motion sickness or seizures | Check @Environment(\\.accessibilityReduceMotion) |
| Material blur without fallback | Unreadable when Reduce Transparency is on | Check @Environment(\\.accessibilityReduceTransparency) |
| Dynamic content without focus update | VoiceOver user unaware of changes | Use @AccessibilityFocusState or post notification |
| Custom gesture with no alternative | VoiceOver users cannot perform gesture | Add .accessibilityAction(named:) |
| Color-only status indication | Color-blind users cannot distinguish states | Add icons or text labels alongside color |
Bringing It All Together
Accessibility in SwiftUI is an ongoing practice, not a one-time checkbox. The good news: SwiftUI provides excellent built-in support. Most work involves adding a modifier or two — not rewriting your codebase. Here is your action plan:
- Turn on VoiceOver right now and navigate your app. Note every place the experience breaks.
- Add
performAccessibilityAudit()tests to CI for every major screen. - Set Dynamic Type to AX5 and fix every layout that breaks.
- Audit your color palette for WCAG AA contrast compliance.
- Replace every custom gesture with an
.accessibilityAction(named:)alternative.
If you want a head start, The Swift Kit ships with accessible design tokens that meet WCAG AA contrast requirements in both light and dark modes, VoiceOver support across every screen, Dynamic Type scaling with @ScaledMetric throughout the design system, and Reduce Motion and Reduce Transparency handling built into every animation. The onboarding, paywall, and settings flows are fully accessible out of the box. Check out the pricing page to see everything included, or browse the documentation to learn how the accessibility system works under the hood.