Let's be honest — accessibility often gets treated as a nice-to-have that teams bolt on at the end of a project. That mindset needs to change. Roughly 1 in 5 people worldwide live with some form of disability, and in 2026 the regulatory landscape is tighter than ever: the European Accessibility Act is enforceable, and ADA Title II deadlines for WCAG 2.1 AA compliance are fast approaching.
But here's the thing that really gets my attention as a developer: accessible apps reach a wider audience and consistently rank higher in App Store search, especially with Apple's new Accessibility Nutrition Labels. So accessibility isn't just the right thing to do — it's a genuine competitive advantage.
SwiftUI gives you a solid head start. Standard views come with sensible defaults for assistive technologies. But defaults alone? Rarely enough. Custom views, image-only buttons, complex gestures, and dynamic layouts all demand deliberate work. This guide covers everything you need to build truly inclusive SwiftUI apps, from core modifiers to automated testing in your CI pipeline.
How SwiftUI Accessibility Works Under the Hood
Every SwiftUI view generates an accessibility element that the system exposes to assistive technologies like VoiceOver, Voice Control, and Switch Control. The framework automatically populates labels, values, and traits for built-in controls. A Button gets the .isButton trait. A Toggle reports its on/off value. A Slider exposes adjustable actions. Pretty nice out of the box.
One of SwiftUI's biggest wins over UIKit? The VoiceOver traversal order matches your view declaration order. No more fighting with imperative accessibility trees. That said, you'll still need to refine things for anything beyond the simplest layouts.
Essential Accessibility Modifiers
SwiftUI provides a rich set of modifiers that map directly to the underlying UIAccessibility API. Mastering these is the foundation of accessible SwiftUI development, so let's walk through the most important ones.
accessibilityLabel — Describing What an Element Is
The label is the first thing VoiceOver reads. Standard Text views inherit their label automatically, but images, icons, and custom views need explicit labels.
// Bad — VoiceOver reads "heart" from the SF Symbol name
Button(action: toggleFavorite) {
Image(systemName: isFavorite ? "heart.fill" : "heart")
}
// Good — VoiceOver reads "Add to favorites" or "Remove from favorites"
Button(action: toggleFavorite) {
Image(systemName: isFavorite ? "heart.fill" : "heart")
}
.accessibilityLabel(isFavorite ? "Remove from favorites" : "Add to favorites")
This is one of those small details that makes a huge difference. Without that label, a VoiceOver user just hears "heart" and has no idea what tapping it actually does.
accessibilityHint — Explaining What Happens Next
Hints give additional context about the result of an interaction. VoiceOver reads them after a brief pause, so keep them short and action-oriented.
Button("Delete", action: deleteItem)
.accessibilityHint("Removes this item permanently")
Don't repeat information the label already conveys. A hint like "Tapping will delete" is redundant when the label says "Delete."
accessibilityValue — Communicating State
Use accessibilityValue for elements whose state changes dynamically but whose label stays constant — think star ratings, progress indicators, or custom steppers.
struct StarRating: View {
let rating: Int
let maxRating: Int
var body: some View {
HStack {
ForEach(1...maxRating, id: \.self) { star in
Image(systemName: star <= rating ? "star.fill" : "star")
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Rating")
.accessibilityValue("\(rating) out of \(maxRating) stars")
}
}
Without that explicit value, VoiceOver would try to read each individual star image — not a great experience.
accessibilityAddTraits and accessibilityRemoveTraits
Traits tell assistive technologies how to interpret and interact with an element. The most common ones you'll reach for are .isButton, .isHeader, .isSelected, .isImage, and .updatesFrequently.
// Mark a tappable card as a button so VoiceOver announces it correctly
CardView(item: item)
.onTapGesture { selectItem(item) }
.accessibilityAddTraits(.isButton)
// Mark section titles as headers for quick VoiceOver navigation
Text("Recent Orders")
.font(.headline)
.accessibilityAddTraits(.isHeader)
That .isHeader trait is especially important. It lets VoiceOver users jump between sections using the rotor, which is a massive time saver on content-heavy screens.
Grouping, Combining, and Hiding Elements
A screen with dozens of small views can be exhausting for VoiceOver users — imagine swiping through every single icon, label, and subtitle in a list row individually. SwiftUI gives you three strategies to control how children are exposed.
Combine Children Into One Element
Use .accessibilityElement(children: .combine) to merge multiple child views into a single VoiceOver stop. The framework concatenates their labels automatically.
HStack {
Image(systemName: "envelope.fill")
Text("3 unread messages")
}
.accessibilityElement(children: .combine)
VoiceOver reads this as one element: "envelope.fill, 3 unread messages." That's okay, but honestly the SF Symbol name isn't ideal. A better approach is to ignore children and set an explicit label on the parent:
HStack {
Image(systemName: "envelope.fill")
Text("3 unread messages")
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("3 unread messages")
Hide Decorative Elements
Purely decorative views — background gradients, separator lines, brand illustrations — should be hidden from the accessibility tree entirely.
// Decorative image — completely hidden from VoiceOver
Image("hero-background")
.accessibilityHidden(true)
// Or use the dedicated initializer for decorative images
Image(decorative: "hero-background")
I'd recommend using Image(decorative:) whenever possible. It makes your intent clear to other developers reading the code, too.
Expose Children Individually
The default .accessibilityElement(children: .contain) preserves each child as a separate VoiceOver stop. This is the right choice for interactive lists, form fields, or any group where each child has its own distinct action.
Dynamic Type: Making Text Scale Gracefully
Dynamic Type lets users choose their preferred text size system-wide. SwiftUI text views respect it automatically when you use system fonts. The real challenge, though, is making sure your layouts don't fall apart at extreme sizes.
Using System Fonts
Text("Welcome back")
.font(.title2) // Scales automatically with Dynamic Type
Simple. Just don't hardcode font sizes and you're already 80% of the way there.
Custom Fonts That Scale
If your design calls for a custom typeface, you can create a scaled version using the .custom(_:size:relativeTo:) API:
Text("Dashboard")
.font(.custom("Avenir-Heavy", size: 22, relativeTo: .title2))
The relativeTo parameter ties your custom font to a specific text style, so it grows and shrinks proportionally with the user's preference. Without it, your custom font stays fixed while everything else scales — and that looks really off.
Adaptive Layouts With dynamicTypeSize
At the largest accessibility sizes, horizontal layouts tend to overflow. Here's a pattern I use constantly — read the @Environment(\.dynamicTypeSize) value and switch between horizontal and vertical arrangements:
struct AdaptiveHStack<Content: View>: View {
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
let content: () -> Content
var body: some View {
if dynamicTypeSize.isAccessibilitySize {
VStack(alignment: .leading, content: content)
} else {
HStack(content: content)
}
}
}
// Usage
AdaptiveHStack {
Image(systemName: "person.circle")
Text("John Appleseed")
Spacer()
Text("Admin")
}
This ensures your layout stays usable even at the five accessibility text sizes (AX1 through AX5). It's a small investment that prevents a lot of broken layouts.
Previewing Dynamic Type
You can test multiple sizes side by side with Xcode previews, which saves a ton of time:
#Preview("Dynamic Type Sizes") {
VStack {
ProfileCard()
.environment(\.dynamicTypeSize, .xSmall)
ProfileCard()
.environment(\.dynamicTypeSize, .xxxLarge)
ProfileCard()
.environment(\.dynamicTypeSize, .accessibility3)
}
}
Managing Accessibility Focus With @AccessibilityFocusState
Programmatic focus control is essential for dynamic interfaces. Think about form validation errors, sheet dismissals, or new content appearing after a network request — if VoiceOver focus doesn't move to the right place, users are left guessing what just changed.
SwiftUI provides @AccessibilityFocusState for exactly this.
Boolean Focus for a Single Element
struct LoginView: View {
@State private var email = ""
@State private var password = ""
@State private var errorMessage: String?
@AccessibilityFocusState private var isErrorFocused: Bool
var body: some View {
Form {
TextField("Email", text: $email)
SecureField("Password", text: $password)
if let errorMessage {
Text(errorMessage)
.foregroundStyle(.red)
.accessibilityFocused($isErrorFocused)
}
Button("Sign In") { validate() }
}
}
private func validate() {
if email.isEmpty {
errorMessage = "Please enter your email address"
isErrorFocused = true
}
}
}
When validation fails, VoiceOver immediately jumps to the error message. Users know exactly what went wrong without having to swipe around looking for it.
Enum-Based Focus for Multiple Targets
For forms with several fields, an enum-based approach keeps things cleaner:
enum CheckoutField: Hashable {
case name, address, card
}
struct CheckoutView: View {
@AccessibilityFocusState private var focus: CheckoutField?
@State private var name = ""
@State private var address = ""
@State private var card = ""
var body: some View {
Form {
TextField("Full Name", text: $name)
.accessibilityFocused($focus, equals: .name)
TextField("Address", text: $address)
.accessibilityFocused($focus, equals: .address)
TextField("Card Number", text: $card)
.accessibilityFocused($focus, equals: .card)
}
}
}
Supporting Reduced Motion and Color Blindness
Accessible apps go well beyond screen readers. Two environment values that developers frequently overlook are accessibilityReduceMotion and accessibilityDifferentiateWithoutColor.
Respecting Reduce Motion
struct PulsingDot: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isAnimating = false
var body: some View {
Circle()
.fill(.green)
.frame(width: 12, height: 12)
.scaleEffect(isAnimating && !reduceMotion ? 1.5 : 1.0)
.animation(
reduceMotion ? nil : .easeInOut(duration: 1).repeatForever(),
value: isAnimating
)
.onAppear { isAnimating = true }
}
}
Setting the animation to nil when Reduce Motion is on is the cleanest approach. The dot still appears — it just doesn't pulse. Users who experience motion sickness or vestibular disorders will thank you.
Differentiating Without Color
Never convey information through color alone. When the user has enabled "Differentiate Without Color," supplement color with icons or text:
struct StatusBadge: View {
@Environment(\.accessibilityDifferentiateWithoutColor) private var noColor
let status: OrderStatus
var body: some View {
HStack(spacing: 4) {
if noColor {
Image(systemName: status.iconName)
}
Text(status.label)
}
.foregroundStyle(status.color)
}
}
enum OrderStatus {
case pending, shipped, delivered
var label: String {
switch self {
case .pending: "Pending"
case .shipped: "Shipped"
case .delivered: "Delivered"
}
}
var color: Color {
switch self {
case .pending: .orange
case .shipped: .blue
case .delivered: .green
}
}
var iconName: String {
switch self {
case .pending: "clock"
case .shipped: "shippingbox"
case .delivered: "checkmark.circle"
}
}
}
Common Accessibility Mistakes and How to Fix Them
Even experienced developers fall into these traps. I've reviewed a lot of SwiftUI codebases over the years, and these four issues come up again and again.
Mistake 1: Using Text + onTapGesture Instead of Button
This is honestly the single most common accessibility bug in SwiftUI apps. A Text with .onTapGesture has no .isButton trait, so VoiceOver announces it as static text. Users have no clue they can interact with it.
// Bad — not announced as interactive
Text("View Details")
.foregroundStyle(.blue)
.onTapGesture { showDetails() }
// Good — VoiceOver announces "View Details, button"
Button("View Details") { showDetails() }
The fix is trivial. Just use a Button. If you need custom styling, use .buttonStyle(.plain) and style away.
Mistake 2: Missing Labels on Icon Buttons
// Bad — VoiceOver reads "trash, button" (or worse, nothing useful)
Button(action: deleteItem) {
Image(systemName: "trash")
}
// Good — VoiceOver reads "Delete item, button"
Button(action: deleteItem) {
Image(systemName: "trash")
}
.accessibilityLabel("Delete item")
SF Symbols have built-in accessibility labels, but they're often too generic. "Trash" technically describes the icon, but "Delete item" describes the action, which is what the user actually needs to know.
Mistake 3: Forgetting Traits on Custom Interactive Views
If you build a tappable card, selection row, or custom toggle from scratch, you need to declare its traits manually. SwiftUI can't infer interactivity from onTapGesture alone.
// This card is tappable but VoiceOver doesn't know it
HStack {
Text(item.title)
Spacer()
Image(systemName: "chevron.right")
}
.onTapGesture { navigate(to: item) }
// Fixed — add button trait and a label
HStack {
Text(item.title)
Spacer()
Image(systemName: "chevron.right")
.accessibilityHidden(true)
}
.onTapGesture { navigate(to: item) }
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)
.accessibilityHint("Opens \(item.title) details")
Mistake 4: Conditionally Swapping Views That Break Focus
When you conditionally replace views with if/else, SwiftUI removes the old view and inserts a new one. This disrupts VoiceOver focus in unpredictable (and frustrating) ways.
// Problematic — VoiceOver loses focus when isLoading toggles
if isLoading {
ProgressView()
} else {
ContentView()
}
// Better — use opacity to preserve view identity
ZStack {
ProgressView()
.opacity(isLoading ? 1 : 0)
.accessibilityHidden(!isLoading)
ContentView()
.opacity(isLoading ? 0 : 1)
.accessibilityHidden(isLoading)
}
The opacity trick keeps both views in the hierarchy, so VoiceOver focus doesn't jump around when the state changes.
Custom Accessibility Actions
Here's a problem that catches people off guard: swipe actions on list rows don't work with VoiceOver. The horizontal swipe gesture is reserved for navigating between elements, so those neat swipe-to-delete and swipe-to-archive actions become invisible.
The fix is custom accessibility actions, which show up in VoiceOver's Actions rotor.
struct MessageRow: View {
let message: Message
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(message.sender).font(.headline)
Text(message.preview).font(.subheadline)
}
Spacer()
Text(message.timestamp, style: .relative)
}
.accessibilityElement(children: .combine)
.accessibilityAction(named: "Mark as read") {
markAsRead(message)
}
.accessibilityAction(named: "Delete") {
delete(message)
}
.accessibilityAction(named: "Archive") {
archive(message)
}
}
}
VoiceOver users swipe up or down to cycle through these actions. Every feature stays accessible without touching your visual design — a nice separation of concerns.
Automated Accessibility Testing With XCTest
Manual testing with VoiceOver is irreplaceable (seriously, turn it on and use your app — you'll find issues you never expected). But you should also automate the checks you can. Since Xcode 15, performAccessibilityAudit() brings Accessibility Inspector audits directly into your UI test suite.
Basic Audit
import XCTest
final class AccessibilityTests: XCTestCase {
let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = true
app.launch()
}
func testHomeScreenAccessibility() throws {
try app.performAccessibilityAudit()
}
}
That's it. One line catches missing labels, insufficient contrast, tiny hit targets, and more.
Targeted Audits
If you want faster test runs or need to fix issues incrementally, you can focus on specific categories:
func testContrastAndDynamicType() throws {
try app.performAccessibilityAudit(for: [.contrast, .dynamicType])
}
func testElementDescriptions() throws {
try app.performAccessibilityAudit(for: .sufficientElementDescription)
}
Filtering Known Issues
Sometimes you'll have false positives or third-party components you can't modify. You can selectively ignore those:
func testWithKnownExclusions() throws {
try app.performAccessibilityAudit { issue in
// Ignore issues from the third-party map component
let isMapIssue = issue.element?.identifier == "mapContainer"
return !isMapIssue
}
}
Integrating Into CI/CD
Add the audit test to your CI pipeline alongside your unit and integration tests. Any accessibility regression immediately fails the build, which prevents issues from reaching users.
A realistic expectation: automated audits catch roughly 30-40% of WCAG issues — things like missing labels, insufficient contrast, hit target sizes, and Dynamic Type support. The rest requires manual testing. But catching the most common regressions automatically is still a huge win.
Accessibility Nutrition Labels in App Store Connect
This is one of my favorite additions from WWDC 2025. Accessibility Nutrition Labels let you declare which accessibility features your app supports directly in App Store Connect. These labels show up on your product page, and (this is the exciting part) users can filter App Store search results by accessibility features.
The supported features you can declare include:
- VoiceOver — Full screen reader support
- Dynamic Type — Text scales with system preferences
- Increase Contrast — UI adapts to high-contrast settings
- Reduce Motion — Animations respect motion preferences
- Voice Control — Full navigation via voice commands
- Switch Control — Compatible with external switches
To add labels, navigate to App Store Connect > Your App > App Information > Accessibility. Select the features your app supports and submit with your next update. Apple recommends testing each declared feature thoroughly — the WWDC25 session "Evaluate your app for Accessibility Nutrition Labels" provides a detailed checklist that's worth watching.
Beyond user trust, these labels are a genuine discoverability advantage. Apps that declare accessibility features appear in accessibility-filtered searches. Apps without labels don't. That's a straightforward way to reach an audience your competitors might be missing.
Accessibility Audit Checklist for SwiftUI Apps
Here's the checklist I run through before every release. Bookmark this one:
- VoiceOver navigation — Every interactive element is reachable and correctly described
- Touch targets — All tappable areas are at least 44x44 points
- Dynamic Type — Layouts remain functional at all text sizes, including accessibility sizes
- Color contrast — Text has a minimum 4.5:1 contrast ratio (3:1 for large text)
- Color independence — Information is never conveyed by color alone
- Reduce Motion — Animations are disabled or simplified when the setting is on
- Traits — Headers, buttons, links, and selected states have correct traits
- Custom actions — Swipe actions and context menus are available via accessibility actions
- Focus management — Focus moves logically after sheet dismissals, navigation, and content changes
- Automated audits —
performAccessibilityAudit()passes in UI tests
Frequently Asked Questions
Does SwiftUI handle accessibility automatically?
Partially. Standard controls like buttons, text fields, toggles, and sliders get automatic labels, values, and traits, which is great. But custom views, image-only buttons, and complex layouts require explicit accessibility modifiers. Think of SwiftUI's built-in accessibility as a solid foundation you still need to build on — not a finished product.
How do I test VoiceOver without a physical device?
The Xcode Accessibility Inspector lets you audit individual elements in the Simulator, and performAccessibilityAudit() in XCTest catches common issues automatically. That said, the Inspector can't replicate the full VoiceOver experience. Gesture navigation, timing, and reading flow are really best tested on a real iPhone with VoiceOver enabled (Settings > Accessibility > VoiceOver). It's worth the effort.
What is the minimum touch target size for accessible iOS apps?
Apple's Human Interface Guidelines recommend a minimum of 44x44 points for all tappable elements. WCAG 2.2 (Success Criterion 2.5.8) recommends at least 24x24 CSS pixels, but Apple's stricter 44-point guideline is the standard to follow for iOS. Use .frame(minWidth: 44, minHeight: 44) or padding to make sure small icons meet this requirement.
How do Accessibility Nutrition Labels affect App Store ranking?
Apple hasn't disclosed a direct ranking boost, but there's a clear discoverability advantage: users can filter search results by accessibility features. Apps that declare VoiceOver, Dynamic Type, or Reduce Motion support appear in those filtered results, while apps without labels simply don't. It's not a silver bullet for ASO, but it puts you in front of an audience that actively seeks accessible apps.
Should I use accessibilityLabel or accessibilityHint?
Use accessibilityLabel to describe what an element is — it's always read first and should be concise (e.g., "Delete item"). Use accessibilityHint to describe what happens when the user interacts with it — VoiceOver reads it after a pause, and users can turn hints off in settings (e.g., "Removes this item permanently"). Every interactive element needs a label. Hints are optional and should only add information that isn't already obvious.