skills/ios-a11y/SKILL.md
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.
npx skillsauth add mikemai2awesome/agent-skills ios-a11yInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Based on Appt Docs and CVS SwiftUI Accessibility.
accessibilityLabel.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 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.
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"
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 = false
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 = false
Gotcha 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.
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 individually
Warning: adding .accessibilityLabel to a container without .accessibilityElement(children: .contain) silently overrides every child element's label — this breaks Voice Control's "Tap [name]" command.
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]
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.
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.
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.
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
}
}
}
}
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 (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.
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.
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.
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 = .emailAddress
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.
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.
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"
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.
UIAccessibility.isVoiceOverRunning
UIAccessibility.isSwitchControlRunning
UIAccessibility.isReduceMotionEnabled
UIAccessibility.isDarkerSystemColorsEnabled
UIAccessibility.isBoldTextEnabled
UIAccessibility.isGrayscaleEnabled
UIAccessibility.preferredContentSizeCategory // current Dynamic Type size
For subscribing to runtime change notifications, see accessibility-apis.md.
For a full testing checklist and how to enable/use VoiceOver, Switch Control, Voice Control, and Keyboard Access, see assistive-features.md.
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)
development
Write minimal, efficient CSS for small or minimalist projects by trusting the browser instead of fighting it. Only use this skill for personal sites, prototypes, simple landing pages, or projects intentionally kept lean — if the project has multiple developers, a component library, a design token system, or more than a handful of CSS files, a more comprehensive CSS approach is needed. If you're about to write a CSS reset, declare a base font-size on :root, set default colors on body, use px for spacing, or reach for physical margin/padding properties, this skill will stop you.
development
Write scalable, well-architected CSS using design tokens, cascade layers, and modern organization patterns. Use this skill as the default for any real project — if it has more than a handful of CSS files, multiple components, a team, a design system, or any kind of token or theming setup, this is the right skill.
development
Create web interfaces with an authentic early-2010s aesthetic. Use this skill when the user wants a 2010s-era, Web 2.0, or retro corporate web look — gradient headers, glossy buttons, skeuomorphic icons, horizontal band layouts, and drop shadows from circa 2010–2014.
development
Define and enforce consistent coding standards across HTML, CSS, and JavaScript. Always use this skill when naming a new class, variable, component, or file; setting up a new project's conventions; choosing a class prefix for a new CSS category; deciding on modifier API names (sizes, shades, hierarchy, breakpoints); or reviewing code for formatting and naming consistency. If you're about to invent a prefix, abbreviation, or modifier name without checking the conventions first, use this skill.