A user opens your app, taps around, everything works. But another user -- one who relies on VoiceOver to navigate their phone -- hits one of your buttons and hears... nothing. Or worse, they hear "button" with no indication of what it does. They're stuck.
This happens constantly in iOS apps. Not because developers don't care, but because the problem is invisible unless you turn on VoiceOver yourself. And most developers don't.
The culprit is almost always the same: a button whose content is an image or icon with no text alternative. SwiftUI makes it easy to build these. AccessLint makes it easy to catch them.
The Problem: Icon-Only Buttons Are Everywhere
SF Symbols changed the way iOS developers build interfaces. Need a share button? Drop in square.and.arrow.up. Settings gear? gearshape. Trash icon? trash. The result is a cleaner visual UI -- but a broken auditory one.
When VoiceOver encounters a Button whose content is an Image, it tries to derive a label. For SF Symbols, it sometimes guesses based on the symbol name. But those guesses are often wrong, overly technical, or just the raw symbol identifier. For custom images, VoiceOver gets nothing at all.
Here's a common pattern in production SwiftUI code:
struct ToolbarView: View {
var body: some View {
HStack {
Button(action: { shareDocument() }) {
Image(systemName: "square.and.arrow.up")
}
Button(action: { deleteDocument() }) {
Image(systemName: "trash")
}
Button(action: { openSettings() }) {
Image("custom-settings-icon")
}
}
}
}
Visually, this toolbar looks fine. Three icons, three obvious actions. But VoiceOver users hear something like:
- "Square and arrow up, button"
- "Trash, button"
- "Button" (no label at all for the custom image)
The first two are technically functional but confusing. The third is completely useless. A VoiceOver user has no way to know what that button does without tapping it and hoping for the best.
The Fix: Add Accessibility Labels
The fix is one modifier: .accessibilityLabel(). It tells VoiceOver exactly what to announce when the user focuses on the element.
struct ToolbarView: View {
var body: some View {
HStack {
Button(action: { shareDocument() }) {
Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel("Share document")
Button(action: { deleteDocument() }) {
Image(systemName: "trash")
}
.accessibilityLabel("Delete document")
Button(action: { openSettings() }) {
Image("custom-settings-icon")
}
.accessibilityLabel("Settings")
}
}
}
Now VoiceOver announces "Share document, button", "Delete document, button", "Settings, button". Each label describes the action, not the icon. A VoiceOver user can navigate this toolbar as confidently as a sighted user.
A few things worth noting about good labels:
- Describe the action, not the icon. "Share document" is better than "Square with up arrow." Users don't care what the icon looks like; they care what it does.
- Keep it concise. "Share" or "Share document" -- not "Tap this button to share the current document with other people via the system share sheet."
- Skip the word "button." VoiceOver already announces the role. If your label says "Share button", users hear "Share button, button."
There's also a shortcut for buttons that pair an icon with text. If your Button contains both an Image and a Text view, SwiftUI uses the text as the accessibility label automatically:
Button(action: { shareDocument() }) {
Label("Share", systemImage: "square.and.arrow.up")
}
No .accessibilityLabel() needed here. The Label view handles it. AccessLint is smart enough to recognize this pattern and won't flag it.
Why This Matters Beyond Compliance
This isn't just about checking a box. WCAG 2.1 Success Criterion 1.1.1 (Non-text Content) requires that all non-text content has a text alternative. SC 4.1.2 (Name, Role, Value) requires that user interface components have accessible names. Both are Level A -- the baseline. If your buttons don't have labels, you fail the most fundamental accessibility requirements.
But compliance aside, unlabeled buttons create real friction for real people. Over 2 million iPhone users in the US alone rely on VoiceOver. Hundreds of millions more use iOS accessibility features like Voice Control, Switch Control, or the Accessibility Keyboard. When your buttons don't have labels, these users can't use your app. That's lost engagement, lost revenue, and lost trust.
How AccessLint Catches This
AccessLint includes a rule specifically for this: A11Y.SWIFTUI.MISSING_LABEL. It scans your SwiftUI source files and flags any Button that contains non-text content (like an Image) without an .accessibilityLabel() modifier in the chain.
Run it against your project:
$ accesslint analyze --path ./Sources
If you have unlabeled buttons, you'll see output like this:
AccessLint Analysis Complete
============================
Files analyzed: 23
Rules run: 25
Duration: 0.42s
Findings: 2
[MAJOR] Major: 2
Top issues:
[MAJOR] [A11Y.SWIFTUI.MISSING_LABEL] Button missing accessibility label
Sources/Views/ToolbarView.swift:8
[MAJOR] [A11Y.SWIFTUI.MISSING_LABEL] Button missing accessibility label
Sources/Views/ToolbarView.swift:16
If you use the --format xcode flag (for Xcode build phase integration), each finding shows as an inline error:
Sources/Views/ToolbarView.swift:8:13: error: [A11Y.SWIFTUI.MISSING_LABEL] Button missing accessibility label: Add .accessibilityLabel("Description of button action") modifier
Sources/Views/ToolbarView.swift:16:13: error: [A11Y.SWIFTUI.MISSING_LABEL] Button missing accessibility label: Add .accessibilityLabel("Description of button action") modifier
The rule is precise. It knows that a Button("Share") { ... } with a string title already has a name. It knows that a Button containing both Image and Text gets its label from the text. It knows that a Label view provides an accessible name. It only flags buttons where VoiceOver genuinely has nothing useful to say.
For UIKit, the companion rule A11Y.UIKIT.BUTTON_MISSING_LABEL catches UIButton instances that have no title and no accessibilityLabel set.
Put It in Your CI Pipeline
Catching these issues locally is good. Catching them in CI is better. Add AccessLint to your GitHub Actions workflow and every pull request gets checked automatically:
name: Accessibility
on:
pull_request:
paths: ['**/*.swift']
jobs:
accesslint:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: SyncTek-LLC/AccessLint@v1
with:
path: ./Sources
fail-on: major
Since MISSING_LABEL is a major severity finding, any PR that introduces an unlabeled image-only button will fail the check. The issue gets fixed in the same PR where it was introduced, not six months later in an accessibility audit.
Try It
AccessLint scans your SwiftUI and UIKit code for accessibility issues -- missing labels, fixed fonts, undersized touch targets, and more. Twenty-five rules, all running in seconds, no simulator required.
Install it and run your first scan:
$ brew tap synctek-llc/accesslint && brew install accesslint
$ accesslint analyze --path ./Sources
Full documentation and pricing at accesslint.app.
Ready to try AccessLint?
Install the CLI and catch your first accessibility violation in under two minutes. Free tier includes 10 rules.
Get Started Free