ios-a11y
Skill17 starsChanged 6 months agoMITView on GitHub
Implement accessibility in iOS apps using Swift, UIKit, and SwiftUI. Use this skill whenever working on any iOS development task that involves: making UI elements accessible to VoiceOver or other assistive technologies, adding or reviewing accessibility labels/hints/traits/actions/values, supporting Dynamic Type or text scaling, respecting Reduce Motion or reduced transparency preferences, adapting to Dark Mode or increased contrast, building accessible forms and inputs, announcing dynamic content changes, managing focus programmatically, customizing accessibility focus order, supporting external keyboard navigation, or auditing iOS code for accessibility issues. Trigger even when the user only says "SwiftUI" or "UIKit" without mentioning "accessibility" explicitly — if they''re building custom controls, modals, forms, lists, or animated views, this skill applies.
1 other project uses a version of this.
Adopted by
Most recently taken up by
- raditia/CraftKittheir own version, first seen Oct 13, 2026
Other versions1 project uses a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 1
- raditia/CraftKit★ 1same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository9 other files it gives its agents.
- brightcove-playerSkill
- format-storybookSkill
- frontend-a11ySkill
- frontend-conventionsSkill
- frontend-design-2010sSkill
- grill-my-designSkill
- more-cssSkill
- socratic-provocateurSkill
- tiny-cssSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
14 rules. About 0 are common, 9 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈9
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 41
Don't include the element's role in the label — VoiceOver announces it separately.
- Very specific: line 79
Use accessibilityValue to describe the current state of adjustable or interactive elements — sliders, steppers, toggles, or progress …
- Distinctive: line 120
When multiple views together form one logical unit, combine them so VoiceOver reads them as a single item.
- Very specific: line 151
Use .contain (not .combine) when you want a group label announced on entry but children to stay individually focusable — the right pattern …
- Distinctive: line 170
When the default left-to-right, top-to-bottom focus order doesn't match logical reading order, override it.
- Distinctive: line 207
When posting from a state change, add a short delay so VoiceOver doesn't skip the announcement:
- Distinctive: line 221
When presenting a new view (modal, bottom sheet, inline expansion), move VoiceOver focus to the right element so the user knows something …
- Very specific: line 246
Use .screenChanged when the whole screen context changes.
- Distinctive: line 290
When an element supports multiple actions (swipe to delete, long-press to share, drag to reorder), expose them as accessibility actions so …
- Very specific: line 346
Never set a fixed height on a view that contains text.
- Very specific: line 382
Every form field needs a visible label and an accessibilityLabel.
- Distinctive: line 457
Every screen needs a title — it orients all users, especially VoiceOver users navigating to a new screen.
- Distinctive: line 497
Dynamic Type: Test at the largest accessibility size.
- Distinctive: line 525
Write manual assertions for what the audit misses (duplicate labels, redundant role words, heading traits):
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
iOS Accessibility
Based on Appt Docs and CVS SwiftUI Accessibility.
Core Principles
- Semantic over custom — Use standard UIKit/SwiftUI controls. They come with accessibility built in.
- Label everything that matters — Every interactive element and meaningful image needs an
accessibilityLabel. - Expose the right role and state — Use traits to communicate what an element is and what state it's in.
- Respect user preferences — Dynamic Type, Reduce Motion, Dark Mode, Bold Text, and increased contrast are signals from the user, not optional polish.
- Test with VoiceOver — Turn it on and use your app without looking. If you can't complete the main flows, something needs fixing.
VoiceOver: Core Attributes
Label and Hint
The label is what VoiceOver announces when the element is focused. The hint explains what happens when you activate it. Keep labels concise and hints optional — only add a hint when the outcome isn't obvious from the label.
SwiftUI:
Button("✕") { dismiss() }
.accessibilityLabel("Close")
.accessibilityHint("Dismisses this sheet")UIKit:
closeButton.accessibilityLabel = "Close"
closeButton.accessibilityHint = "Dismisses this sheet"Don't include the element's role in the label — VoiceOver announces it separately. So "Submit button" is wrong; just "Submit" is right.
For pronunciation overrides and controlling how VoiceOver speaks individual words, see accessibility-apis.md.
Traits (Role and State)
Traits tell VoiceOver what an element is and how to interact with it. UIKit calls them UIAccessibilityTraits; SwiftUI uses AccessibilityTraits.
SwiftUI:
Text("Recent Orders")
.accessibilityAddTraits(.isHeader)
Toggle("Dark mode", isOn: $isDarkMode)
// Toggle already has the correct traits
Text("Step 1 of 3")
.accessibilityAddTraits(.updatesFrequently)UIKit:
sectionLabel.accessibilityTraits = .header
linkButton.accessibilityTraits = .link
toggleSwitch.accessibilityTraits = [.button, .selected] // when on
disabledButton.accessibilityTraits.remove(.button)
disabledButton.accessibilityTraits.insert(.notEnabled)Common traits: .button .link .header .image .adjustable .selected .notEnabled .staticText .searchField
For the .adjustable trait (custom sliders/steppers with increment/decrement actions), accessibilityRepresentation for fully custom controls, and accessibilityRespondsToUserInteraction, see accessibility-apis.md.
Value
Use accessibilityValue to describe the current state of adjustable or interactive elements — sliders, steppers, toggles, or progress indicators.
SwiftUI:
Slider(value: $volume, in: 0...1)
.accessibilityLabel("Volume")
.accessibilityValue("\(Int(volume * 100)) percent")UIKit:
volumeSlider.accessibilityLabel = "Volume"
volumeSlider.accessibilityValue = "\(Int(volumeSlider.value * 100)) percent"Hiding Elements
Decorative images, visual dividers, and purely presentational elements should be hidden from assistive technologies.
SwiftUI:
Image("decorative-background").accessibilityHidden(true)
// For icons paired with a label, hide the icon: Image(...).accessibilityHidden(true)UIKit:
decorativeImageView.isAccessibilityElement = false
separatorView.isAccessibilityElement = falseGrouping and Order
Combining Elements
When multiple views together form one logical unit, combine them so VoiceOver reads them as a single item.
SwiftUI:
HStack {
Image(systemName: "star.fill")
.accessibilityHidden(true)
VStack(alignment: .leading) {
Text("Highly Rated")
Text("4.8 out of 5")
}
}
.accessibilityElement(children: .combine)
// VoiceOver reads: "Highly Rated, 4.8 out of 5"UIKit:
containerView.isAccessibilityElement = true
containerView.accessibilityLabel = "Highly Rated, 4.8 out of 5"
imageView.isAccessibilityElement = false
titleLabel.isAccessibilityElement = false
subtitleLabel.isAccessibilityElement = falseGotcha when combining: if a child Button is combined with .combine, its label won't transfer. Either remove .isButton from the child first, or set .accessibilityLabel explicitly on the combined parent.
Grouping Controls
Use .contain (not .combine) when you want a group label announced on entry but children to stay individually focusable — the right pattern for form sections, radio groups, and card regions.
SwiftUI:
VStack {
Text("Shipping address")
TextField("Street", text: $street)
TextField("City", text: $city)
}
.accessibilityElement(children: .contain)
.accessibilityLabel("Shipping address")
// VoiceOver announces "Shipping address, group" on entry, then reads each field individuallyWarning: adding .accessibilityLabel to a container without .accessibilityElement(children: .contain) silently overrides every child element's label — this breaks Voice Control's "Tap [name]" command.
Custom Ordering
When the default left-to-right, top-to-bottom focus order doesn't match logical reading order, override it.
SwiftUI:
VStack {
Text("$29.99")
.accessibilitySortPriority(2) // focused first
Text("Price")
.accessibilitySortPriority(1)
}UIKit: Set accessibilityElements on the container to define explicit order:
containerView.accessibilityElements = [titleLabel, priceLabel, addToCartButton]Focus Management
Announcements
Post announcements to notify users of assistive technologies about important, non-visual changes — a form submitted, an item added to cart, an error appearing.
SwiftUI / UIKit (both):
// iOS 15+
AccessibilityNotification.Announcement("Item added to cart").post()
// All iOS versions
UIAccessibility.post(notification: .announcement, argument: "Item added to cart")When posting from a state change, add a short delay so VoiceOver doesn't skip the announcement:
.onChange(of: itemAdded) { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
AccessibilityNotification.Announcement("Item added to cart").post()
}
}For live regions (auto-announcing in-place content updates) and the full notification reference, see accessibility-apis.md.
Moving Focus
When presenting a new view (modal, bottom sheet, inline expansion), move VoiceOver focus to the right element so the user knows something changed.
SwiftUI:
@AccessibilityFocusState private var confirmFocused: Bool
VStack {
if showConfirmation {
Text("Order confirmed!")
.accessibilityFocused($confirmFocused)
}
}
.onChange(of: showConfirmation) { newValue in
if newValue { confirmFocused = true }
}UIKit:
UIAccessibility.post(notification: .screenChanged, argument: newViewController.view)
UIAccessibility.post(notification: .layoutChanged, argument: specificView)Use .screenChanged when the whole screen context changes; .layoutChanged for in-place updates.
Modals
Mark custom overlays so VoiceOver can't wander into background content. SwiftUI sheets and .fullScreenCover handle this automatically.
// SwiftUI custom overlay — also handle two-finger scrub to dismiss
VStack { /* modal content */ }
.accessibilityAddTraits(.isModal)
.accessibilityAction(.escape) { isPresented = false }
// UIKit
modalContainerView.accessibilityViewIsModal = true
UIAccessibility.post(notification: .screenChanged, argument: modalContainerView)For the escape gesture protocol, custom rotor entries, and focus indicators for Keyboard Access, see accessibility-apis.md.
Keyboard Dismiss Focus
Text fields don't return VoiceOver focus after keyboard dismissal. Use @AccessibilityFocusState to send it back explicitly:
@AccessibilityFocusState private var fieldFocused: Bool
TextField("Name", text: $name)
.accessibilityFocused($fieldFocused)
.toolbar {
ToolbarItem(placement: .keyboard) {
Button("Done") {
dismissKeyboard()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
fieldFocused = true
}
}
}
}Custom Actions
When an element supports multiple actions (swipe to delete, long-press to share, drag to reorder), expose them as accessibility actions so Switch Control and VoiceOver users can access them without the gesture.
SwiftUI:
Text(item.name)
.accessibilityAction(named: "Delete") { deleteItem(item) }
.accessibilityAction(named: "Share") { shareItem(item) }
.accessibilityAction(named: "Move up") { moveItemUp(item) }UIKit:
cell.accessibilityCustomActions = [
UIAccessibilityCustomAction(name: "Delete") { [weak self] _ in
self?.deleteItem(item); return true
}
]For drag-and-drop accessibility and Switch Control scanning behavior, see assistive-features.md.
Magic Tap
Magic Tap (two-finger double-tap anywhere on screen) toggles the app's primary action — play/pause, answer/end a call. Define at most one per screen. See accessibility-apis.md for the implementation.
Visual Adaptations
Dynamic Type
Text that doesn't scale with the user's preferred font size is one of the most common iOS accessibility failures. Always use text styles; never hard-code font sizes.
SwiftUI:
Text("Hello world")
.font(.headline) // respects user's size preference
@ScaledMetric(relativeTo: .body) var iconSize: CGFloat = 24
Image(systemName: "star")
.frame(width: iconSize, height: iconSize)UIKit:
label.font = UIFont.preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
// Never: UIFont.systemFont(ofSize: 16)Never set a fixed height on a view that contains text. Use .numberOfLines = 0 on UILabel.
For text spacing overrides, preventing truncation at accessibility sizes, reflow layouts, and localization, see visual-adaptations.md.
Reduce Motion
Users with vestibular disorders may have Reduce Motion enabled. Check this before playing animations.
SwiftUI:
@Environment(\.accessibilityReduceMotion) var reduceMotion
Circle()
.scaleEffect(isAnimating ? 1.2 : 1.0)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.6), value: isAnimating)UIKit:
if UIAccessibility.isReduceMotionEnabled {
view.alpha = isVisible ? 1 : 0
} else {
UIView.animate(withDuration: 0.4) { view.alpha = isVisible ? 1 : 0 }
}For Large Content Viewer, Reduce Transparency, Dark Mode, Increased Contrast, Bold Text, Smart Invert, Dim Flashing Lights, and audio/media accessibility, see visual-adaptations.md.
Input Accessibility
Labels for Input Fields
Every form field needs a visible label and an accessibilityLabel. A placeholder alone is not sufficient — it disappears when the user starts typing.
SwiftUI:
VStack(alignment: .leading) {
Text("Email address")
.font(.caption)
TextField("Email address", text: $email)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.accessibilityLabel("Email address")
}UIKit:
emailTextField.placeholder = "Email address"
emailTextField.accessibilityLabel = "Email address"
emailTextField.keyboardType = .emailAddress
emailTextField.textContentType = .emailAddressVoice Control Labels
Voice Control users activate elements by speaking their visible label. When a label is ambiguous or not naturally speakable, use accessibilityInputLabels to provide alternatives.
Button("→") { nextPage() }
.accessibilityLabel("Next page")
.accessibilityInputLabels(["Next", "Next page", "Forward"])For how Voice Control's "Show Names" and "Show Numbers" modes work, and how to verify your UI, see assistive-features.md.
Error Handling
Show the error message visually and announce it so VoiceOver users know something is wrong.
SwiftUI:
VStack(alignment: .leading) {
TextField("Email", text: $email)
.accessibilityLabel("Email")
if let error = emailError {
Text(error).foregroundStyle(.red).font(.caption)
}
}
.onChange(of: emailError) { newError in
if let error = newError {
AccessibilityNotification.Announcement(error).post()
}
}UIKit:
func showError(_ message: String) {
errorLabel.text = message
errorLabel.isHidden = false
UIAccessibility.post(notification: .announcement, argument: message)
}For keyboard type and content type, tap target sizing, timing adjustments, and accessible authentication, see input-patterns.md.
Screen Structure
Screen Title
Every screen needs a title — it orients all users, especially VoiceOver users navigating to a new screen.
SwiftUI: NavigationStack { ContentView().navigationTitle("Order History") }
UIKit: navigationItem.title = "Order History"
Section Headers
Mark section headers so VoiceOver users can jump between sections with the rotor.
SwiftUI: Text("Recent").font(.headline).accessibilityAddTraits(.isHeader)
UIKit: sectionLabel.accessibilityTraits = .header
For screen orientation support, see visual-adaptations.md.
Checking AT State at Runtime
UIAccessibility.isVoiceOverRunning
UIAccessibility.isSwitchControlRunning
UIAccessibility.isReduceMotionEnabled
UIAccessibility.isDarkerSystemColorsEnabled
UIAccessibility.isBoldTextEnabled
UIAccessibility.isGrayscaleEnabled
UIAccessibility.preferredContentSizeCategory // current Dynamic Type sizeFor subscribing to runtime change notifications, see accessibility-apis.md.
Testing
Manual Testing
- VoiceOver: Navigate core flows without looking at the screen. Every element should have a meaningful label, correct role, and logical focus order.
- Accessibility Inspector: Xcode → Open Developer Tool → Accessibility Inspector. Run audits for missing labels, small targets, and contrast issues.
- Dynamic Type: Test at the largest accessibility size; verify nothing clips or truncates.
- Voice Control: Settings → Accessibility → Voice Control. Use "Show Names" to verify every interactive element has a unique, speakable label.
For a full testing checklist and how to enable/use VoiceOver, Switch Control, Voice Control, and Keyboard Access, see assistive-features.md.
Automated Testing with XCTest
performAccessibilityAudit() (iOS 17+) catches missing labels, small tap targets, contrast failures, and text clipping:
func testMyScreen() throws {
let app = XCUIApplication()
app.launch()
navigateToMyScreen(app)
try app.performAccessibilityAudit()
app.swipeUp() // audit below-fold content too
try app.performAccessibilityAudit()
}Filter known false positives:
try app.performAccessibilityAudit { issue in
issue.auditType == .contrast
}Write manual assertions for what the audit misses (duplicate labels, redundant role words, heading traits):
// XCTest IDs go in .accessibilityIdentifier() — VoiceOver never reads them
XCTAssertFalse(app.buttons["closeButton"].label.isEmpty)
XCTAssertFalse(app.buttons["closeButton"].label.lowercased().contains("button"))
XCTAssertNotEqual(app.buttons["edit1"].label, app.buttons["edit2"].label)Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

