
Axiom Accessibility
- 658 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-accessibility is an agent skill that audits and fixes Apple app accessibility—VoiceOver labels, Dynamic Type, contrast, focus order, and WCAG-aligned checks—for developers who need Axiom apps to pass review and rem
About
axiom-accessibility in charleswiltgen/axiom guides developers through auditing and remediating accessibility in Apple platform apps built with the Axiom workflow. The skill covers VoiceOver label correctness, Dynamic Type scaling, color contrast, keyboard and focus order, and WCAG-aligned checks that commonly fail App Store review or frustrate assistive-technology users. Agents walk issue discovery on native UI surfaces, propose concrete trait and label fixes, and validate that controls remain operable under VoiceOver and larger text sizes. Developers reach for axiom-accessibility when an iOS or macOS build is nearing submission, after QA reports confusing screen-reader navigation, or when contrast and focus bugs appear in production-like builds. Output is a prioritized fix list tied to Apple accessibility expectations rather than generic web-only guidance. Triggers include VoiceOver failures, Dynamic Type layout breaks, insufficient contrast, incorrect accessibility traits, and pre-release accessibility review for Axiom apps.
- VoiceOver and assistive label guidance
- Dynamic Type and contrast checks
- Focus order and hit-target remediation
- Pre-submission accessibility test pass
Axiom Accessibility by the numbers
- 658 all-time installs (skills.sh)
- Ranked #513 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-accessibilityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 658 |
|---|---|
| repo stars | ★ 1.1k |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you fix VoiceOver and WCAG issues in Apple apps?
Audit and fix Apple app accessibility: VoiceOver labels, Dynamic Type, contrast, focus order, and WCAG-aligned checks so Axiom apps pass review and remain usable for assistive-tech users.
Who is it for?
iOS and macOS developers shipping Axiom apps who must pass App Store accessibility review and support VoiceOver, Dynamic Type, and WCAG-aligned usage.
Skip if: Web-only React or Android projects with no Apple native UI, or teams that only need automated Lighthouse scans without platform-specific remediation.
When should I use this skill?
User reports VoiceOver label problems, Dynamic Type breakage, contrast failures, focus-order bugs, or asks for an accessibility audit before Apple app review.
What you get
Prioritized accessibility audit findings, VoiceOver label and trait fixes, contrast corrections, and validated focus-order changes for assistive-tech users.
- Accessibility audit findings
- VoiceOver and trait fix list
- Contrast and focus-order corrections
Files
Accessibility
You MUST use this skill for ANY accessibility work including VoiceOver, Dynamic Type, color contrast, WCAG compliance, and UX flow auditing.
Quick Reference
| Symptom / Task | Reference |
|---|---|
| VoiceOver labels, hints, navigation | See skills/accessibility-diag.md |
| Dynamic Type scaling violations | See skills/accessibility-diag.md |
| Dynamic Type on tvOS (Large Text, tvOS 27) | See skills/accessibility-diag.md |
| Long-form reading apps (continuous reading, Speak Screen, text navigation) | See skills/accessibility-diag.md |
| Captions & subtitle styling in video players (generated subtitles, style preview) | See skills/accessibility-diag.md |
| Custom control technique choice (adjustable, passthrough, direct touch) | See skills/accessibility-diag.md |
| Accessibility Nutrition Labels | See skills/accessibility-diag.md |
| Color contrast (WCAG AA/AAA) | See skills/accessibility-diag.md |
| Touch target sizes (< 44x44pt) | See skills/accessibility-diag.md |
| Keyboard navigation (iPadOS/macOS) | See skills/accessibility-diag.md |
| Reduce Motion support | See skills/accessibility-diag.md |
| Assistive Access (cognitive, iOS 17+) | See skills/accessibility-diag.md |
| Accessibility Inspector workflows | See skills/accessibility-diag.md |
| App Store Review preparation | See skills/accessibility-diag.md |
| UX dead ends, dismiss traps | See skills/ux-flow-audit.md |
| Buried CTAs, missing empty states | See skills/ux-flow-audit.md |
| Missing loading/error states | See skills/ux-flow-audit.md |
| Deep link dead ends | See skills/ux-flow-audit.md |
| Accessibility dead ends (gesture-only) | See skills/ux-flow-audit.md |
| watchOS-specific (VoiceOver rotor on Digital Crown, AssistiveTouch, Double Tap) | See skills/watchos-a11y.md |
Cross-Suite Routes
- Full watchOS development context → See axiom-watchos
- Live accessibility validation on the simulator (set toggles, assert announcements) →
simulator-testeragent +xcui— see axiom-tools (skills/xcui-ref.md)
Decision Tree
digraph accessibility {
start [label="Accessibility issue" shape=ellipse];
what [label="What type?" shape=diamond];
start -> what;
what -> "skills/accessibility-diag.md" [label="VoiceOver/labels/hints"];
what -> "skills/accessibility-diag.md" [label="Dynamic Type"];
what -> "skills/accessibility-diag.md" [label="color contrast"];
what -> "skills/accessibility-diag.md" [label="touch targets"];
what -> "skills/accessibility-diag.md" [label="keyboard nav"];
what -> "skills/accessibility-diag.md" [label="Reduce Motion"];
what -> "skills/accessibility-diag.md" [label="Assistive Access"];
what -> "skills/accessibility-diag.md" [label="custom control techniques"];
what -> "skills/accessibility-diag.md" [label="continuous reading / text navigation"];
what -> "skills/accessibility-diag.md" [label="captions / subtitle styling"];
what -> "skills/accessibility-diag.md" [label="App Store prep / Nutrition Labels"];
what -> "skills/ux-flow-audit.md" [label="UX dead end/dismiss trap"];
what -> "skills/ux-flow-audit.md" [label="missing states"];
what -> "skills/watchos-a11y.md" [label="watchOS VoiceOver / AssistiveTouch / Double Tap"];
what -> "accessibility-auditor" [label="automated scan" shape=box];
}1. ANY VoiceOver, Dynamic Type (including tvOS Large Text), contrast, touch target, or WCAG issue → skills/accessibility-diag.md 2. Assistive Access (cognitive disabilities, iOS 17+) → skills/accessibility-diag.md 3. App Store accessibility rejection or Nutrition Labels → skills/accessibility-diag.md 4. Reading app: VoiceOver stops at paragraphs/pages, Speak Screen halts → skills/accessibility-diag.md 5. UX dead ends, dismiss traps, buried CTAs, missing states → skills/ux-flow-audit.md 6. watchOS-specific accessibility (rotor on Digital Crown, AssistiveTouch, Double Tap) → skills/watchos-a11y.md 7. Want automated accessibility scan? → accessibility-auditor agent or /axiom:audit accessibility
Automated Scanning
Accessibility audit → Launch accessibility-auditor agent or /axiom:audit accessibility
- VoiceOver labels and hints
- Dynamic Type violations
- Color contrast failures
- WCAG compliance scanning
UX flow audit → Launch ux-flow-auditor agent
- Dead-end views, dismiss traps
- Buried CTAs, missing empty/loading/error states
- Deep link dead ends, accessibility dead ends
Critical Patterns
Image Accessibility
- Use
Image(decorative: "photo")for purely decorative images — automatically hidden from VoiceOver (equivalent toaccessibilityHidden(true)but semantically clearer) - Use
accessibilityInputLabels()for buttons with complex or changing labels — improves Voice Control accuracy by providing alternative labels - Respect
accessibilityDifferentiateWithoutColorenvironment value — when active, provide non-color cues (icons, patterns, labels) alongside color indicators
Anti-Rationalization
| Thought | Reality |
|---|---|
| "I'll add VoiceOver labels when I'm done building" | Accessibility is foundational, not polish. accessibility-diag prevents App Store rejection. |
| "My app doesn't need accessibility" | All apps need accessibility. It's required by App Store guidelines and benefits all users. |
| "Dynamic Type just needs .scaledFont" | Dynamic Type has 7 common violations. accessibility-diag catches them all. |
| "Color contrast looks fine to me" | Visual assessment is unreliable. WCAG ratios require measurement. accessibility-diag validates. |
| "UX issues are just polish" | UX dead ends cause 1-star reviews. They're defects, not enhancements. |
| "The dismiss gesture handles it" | fullScreenCover has no dismiss gesture. That's the trap. |
Example Invocations
User: "My button isn't being read by VoiceOver" → See skills/accessibility-diag.md
User: "How do I support Dynamic Type?" → See skills/accessibility-diag.md
User: "Check my app for accessibility issues" → See skills/accessibility-diag.md
User: "Prepare for App Store accessibility review" → See skills/accessibility-diag.md
User: "Scan my app for accessibility issues automatically" → Launch accessibility-auditor agent
User: "How do I support Assistive Access?" → See skills/accessibility-diag.md
User: "How do I prepare my tvOS app for Large Text?" → See skills/accessibility-diag.md
User: "VoiceOver stops reading at the end of each page in my book app" → See skills/accessibility-diag.md
User: "How do I let users restyle subtitles or get generated captions in my video player?" → See skills/accessibility-diag.md
User: "Check for UX dead ends and dismiss traps" → See skills/ux-flow-audit.md
User: "My fullScreenCover has no way to dismiss" → See skills/ux-flow-audit.md
User: "Are there missing empty states in my app?" → See skills/ux-flow-audit.md
Accessibility Diagnostics
Overview
Systematic accessibility diagnosis and remediation for Apple platform apps. Covers the most common accessibility issues that cause App Store rejections and user complaints.
Core principle Accessibility is not optional. iOS apps must support VoiceOver, Dynamic Type, and sufficient color contrast to pass App Store Review. Users with disabilities depend on these features.
When to Use This Skill
- Fixing VoiceOver navigation issues (missing labels, wrong element order)
- Supporting Dynamic Type (text scaling for vision disabilities), including tvOS Large Text
- Meeting color contrast requirements (WCAG AA/AAA)
- Fixing touch target size violations (< 44x44pt)
- Adding keyboard navigation (iPadOS/macOS)
- Supporting Reduce Motion (vestibular disorders)
- Supporting Assistive Access (cognitive disabilities)
- Making long-form reading apps work with VoiceOver continuous reading and Speak Screen
- Adding captions to a video player — generated subtitles (new in the 27 releases) and live subtitle style preview (iOS 26.4+)
- Preparing for App Store Review accessibility requirements and Accessibility Nutrition Labels
- Responding to user complaints about accessibility
The 7 Critical Accessibility Issues
1. VoiceOver Labels & Hints (CRITICAL - App Store Rejection)
Problem Missing or generic accessibility labels prevent VoiceOver users from understanding UI purpose.
WCAG 4.1.2 Name, Role, Value (Level A)
Common violations
// ❌ WRONG - No label (VoiceOver says "Button")
Button(action: addToCart) {
Image(systemName: "cart.badge.plus")
}
// ❌ WRONG - Generic label
.accessibilityLabel("Button")
// ❌ WRONG - Reads implementation details
.accessibilityLabel("cart.badge.plus") // VoiceOver: "cart dot badge dot plus"
// ✅ CORRECT - Descriptive label
Button(action: addToCart) {
Image(systemName: "cart.badge.plus")
}
.accessibilityLabel("Add to cart")
// ✅ CORRECT - With hint for complex actions
.accessibilityLabel("Add to cart")
.accessibilityHint("Double-tap to add this item to your shopping cart")When to use hints
- Action is not obvious from label ("Add to cart" is obvious, no hint needed)
- Multi-step interaction ("Swipe right to confirm, left to cancel")
- State change ("Double-tap to toggle notifications on or off")
Decorative elements
// ✅ CORRECT - Hide decorative images from VoiceOver
Image("decorative-pattern")
.accessibilityHidden(true)
// ✅ CORRECT - Combine multiple elements into one label
HStack {
Image(systemName: "star.fill")
Text("4.5")
Text("(234 reviews)")
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Rating: 4.5 stars from 234 reviews")Testing
- Enable VoiceOver: Cmd+F5 (simulator) or triple-click side button (device)
- Navigate: Swipe right/left to move between elements
- Listen: Does VoiceOver announce purpose clearly?
- Check order: Does navigation order match visual layout?
---
2. Dynamic Type Support (HIGH - User Experience)
Problem Fixed font sizes prevent users with vision disabilities from reading text.
WCAG 1.4.4 Resize Text (Level AA - support 200% scaling without loss of content/functionality)
Common violations
// ❌ WRONG - Fixed size, won't scale
Text("Price: $19.99")
.font(.system(size: 17))
UILabel().font = UIFont.systemFont(ofSize: 17)
// ❌ WRONG - Custom font without scaling
Text("Headline")
.font(Font.custom("CustomFont", size: 24))
// ✅ CORRECT - SwiftUI semantic styles (auto-scales)
Text("Price: $19.99")
.font(.body)
Text("Headline")
.font(.headline)
// ✅ CORRECT - UIKit semantic styles
label.font = UIFont.preferredFont(forTextStyle: .body)
// ✅ CORRECT - Custom font with scaling
let customFont = UIFont(name: "CustomFont", size: 24)!
label.font = UIFontMetrics.default.scaledFont(for: customFont)
label.adjustsFontForContentSizeCategory = trueCustom sizes that scale with Dynamic Type
// ❌ WRONG - Fixed size, won't scale
Text("Price: $19.99")
.font(.system(size: 17))
// ⚠️ ACCEPTABLE - Custom font without scaling (accessibility violation)
Text("Headline")
.font(Font.custom("CustomFont", size: 24))
// ✅ GOOD - Custom-named font that scales relative to a text style
Text("Large Title")
.font(.custom("CustomFont", size: 60, relativeTo: .largeTitle))
Text("Custom Headline")
.font(.custom("CustomFont", size: 24, relativeTo: .title2))
// ✅ GOOD - System font at a custom size that scales with Dynamic Type
@ScaledMetric(relativeTo: .title2) private var headlineSize: CGFloat = 24
Text("Custom Headline")
.font(.system(size: headlineSize))
// ✅ BEST - Use semantic styles when possible
Text("Headline")
.font(.headline)How `relativeTo:` works
- Base size: Your exact point size (24pt, 60pt, etc.)
- Scales with: The text style you specify (
.title2,.largeTitle, etc.) - Result: When user increases text size in Settings, your custom size grows proportionally
There is no Font.system(size:).relativeTo(_:) — the only relativeTo: Font factory is the static Font.custom(_:size:relativeTo:). To scale a system font at a custom size, drive it with @ScaledMetric(relativeTo:) (or UIFontMetrics(forTextStyle:).scaledValue(for:) in UIKit).
Example
.title2base: ~22pt → Your custom: 24pt (1.09x larger)- User increases to "Extra Large" text
.title2grows to ~28pt → Your custom grows to ~30.5pt (maintains 1.09x ratio)
Fix hierarchy (best to worst) 1. Best: Use semantic styles (.title, .body, .caption) 2. Good: Font.custom(_:size:relativeTo:) or @ScaledMetric(relativeTo:) for required custom sizes 3. Acceptable: Custom font with .dynamicTypeSize() modifier 4. Unacceptable: Fixed sizes that never scale
SwiftUI text styles
.largeTitle- 34pt (scales to 44pt at accessibility sizes).title- 28pt.title2- 22pt.title3- 20pt.headline- 17pt semibold.body- 17pt (default).callout- 16pt.subheadline- 15pt.footnote- 13pt.caption- 12pt.caption2- 11pt
Layout considerations
The font scaling is the easy half. *Clipping is almost always a fixed frame, not a fixed font* — text that scales correctly still gets cut off when it grows inside a hardcoded height, a single-line cap, or a horizontal stack that runs out of width. Switch the font to a semantic style AND free the container.
// ❌ WRONG - Fixed frame clips, single line truncates
Text("Long product description...")
.font(.body)
.frame(height: 50)
.lineLimit(1)
// ✅ CORRECT - Let the text grow vertically
Text("Long product description...")
.font(.body)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)At accessibility sizes a horizontal row of label + control overflows. Reflow to vertical instead of capping the type size — capping defeats the user's setting and risks rejection.
// ❌ WRONG - capping the size hides text the user asked for
HStack {
Text("Label:")
Text("Value")
}
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
// ✅ CORRECT - reflow HStack → VStack at accessibility sizes
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
let layout = typeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading))
: AnyLayout(HStackLayout())
layout {
Text("Label:")
Text("Value")
}
}Dynamic Type Comes to tvOS tvOS27
Large Text support arrives on tvOS 27, bringing system-wide text scaling to every app on the platform (WWDC 2026-221). Users enable it in Settings → Accessibility → Display → Text Size. Apps that hardcode sizes now break on Apple TV the same way they would on iPhone.
Everything above applies unchanged — the APIs have existed on tvOS all along; what's new is that the system setting now drives them:
- SwiftUI semantic styles and
Font.custom(_:size:relativeTo:)scale automatically - UIKit needs
UIFont.preferredFont(forTextStyle:)+adjustsFontForContentSizeCategory = true - Free the containers: flexible constraints (
maxWidth: .infinity), no fixed frames
tvOS shelf layouts need count adaptation, not just font scaling — six posters per row won't fit when titles grow:
struct MovieShelf: View {
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 40) {
ForEach(movies) { movie in
MovieCell(movie: movie)
.containerRelativeFrame(
.horizontal,
count: dynamicTypeSize.isAccessibilitySize ? 4 : 6,
spacing: 40)
}
}
}
}
}Card cells reflow image-beside-text to a vertical stack at accessibility sizes (same AnyLayout pattern as above). In UIKit, drive a UIStackView axis flip from the content size category and re-evaluate on trait changes:
final class CardCell: UICollectionViewCell {
let stack = UIStackView()
// Call ONCE after init — not on every dequeue, or handlers stack up
func setUpAdaptiveLayout() {
updateAxis()
registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) {
(cell: Self, _: UITraitCollection) in
cell.updateAxis()
}
}
private func updateAxis() {
stack.axis = traitCollection.preferredContentSizeCategory.isAccessibilityCategory
? .vertical : .horizontal
}
}tvOS text sizes range from the Large default up through the accessibility categories, so plan for the same extremes as iPhone. For titles that still overflow at fewer columns, WWDC 2026-221 suggests a custom marquee strategy — gate the scrolling on Reduce Motion (Section 6). Test systematically with Large Text enabled, then declare Larger Text support in your app's Accessibility Nutrition Labels for tvOS in App Store Connect.
Testing
1. Xcode Preview: Environment override
.environment(\.dynamicTypeSize, .accessibility3)2. Simulator: Settings → Accessibility → Display & Text Size → Larger Text → Drag to maximum
3. Device: Settings → Accessibility → Display & Text Size → Larger Text
4. tvOS: Settings → Accessibility → Display → Text Size
5. Check: Does text remain readable? Does layout adapt? Is any text clipped?
---
3. Color Contrast (HIGH - Vision Disabilities)
Problem Low contrast text is unreadable for users with vision disabilities or in bright sunlight.
WCAG
- 1.4.3 Contrast (Minimum) — Level AA
- Normal text (< 18pt): 4.5:1 contrast ratio
- Large text (≥ 18pt or ≥ 14pt bold): 3:1 contrast ratio
- 1.4.6 Contrast (Enhanced) — Level AAA
- Normal text: 7:1 contrast ratio
- Large text: 4.5:1 contrast ratio
Common violations
// ❌ WRONG - Low contrast (1.8:1 - fails WCAG)
Text("Warning")
.foregroundColor(.yellow) // on white background
// ❌ WRONG - Low contrast in dark mode
Text("Info")
.foregroundColor(.gray) // on black background
// ✅ CORRECT - High contrast (7:1+ passes AAA)
Text("Warning")
.foregroundColor(.orange) // or .red
// ✅ CORRECT - System colors adapt to light/dark mode
Text("Info")
.foregroundColor(.primary) // Black in light mode, white in dark
Text("Secondary")
.foregroundColor(.secondary) // Automatic high contrastDifferentiate Without Color
// ❌ WRONG - Color alone indicates status
Circle()
.fill(isAvailable ? .green : .red)
// ✅ CORRECT - Color + icon/text
HStack {
Image(systemName: isAvailable ? "checkmark.circle.fill" : "xmark.circle.fill")
Text(isAvailable ? "Available" : "Unavailable")
}
.foregroundColor(isAvailable ? .green : .red)
// ✅ CORRECT - Respect system preference
if UIAccessibility.shouldDifferentiateWithoutColor {
// Use patterns, icons, or text instead of color alone
}Contrast Reference (Measure, Don't Eyeball)
Light gray on white is the classic failure — it looks "subtle" to a designer but is unreadable for low-vision users and in sunlight. Never judge by eye. Run the Accessibility Inspector Audit tab (flags every failing pair automatically) or sample exact hex with the Digital Color Meter, then compare against this table.
| Foreground on white | Ratio | Verdict |
|---|---|---|
Black #000000 | 21:1 | AAA (any size) |
Dark gray #595959 | ~7:1 | AAA normal text |
Medium gray #767676 | ~4.5:1 | AA floor — normal text |
Gray #8E8E8E | ~3:1 | Large text / UI components only |
Light gray #959595 | ~2.8:1 | FAILS all text |
#767676 is the darkest gray that still passes AA for body text — anything lighter needs to be ≥18pt (or ≥14pt bold) to qualify as "large text" at 3:1.
Testing
1. Accessibility Inspector → Audit tab → Run Audit — surfaces every contrast failure with the measured ratio 2. Digital Color Meter (or Color Contrast Analyzer) to sample exact hex when iterating on brand colors 3. Check both light and dark mode — a pair that passes in one can fail in the other 4. Settings → Accessibility → Display & Text Size → Increase Contrast (verify it still passes with this ON)
---
4. Touch Target Sizes (MEDIUM - Motor Disabilities)
Problem Small tap targets are difficult or impossible for users with motor disabilities.
WCAG 2.5.5 Target Size (Level AAA - 44x44pt minimum)
Apple HIG 44x44pt minimum for all tappable elements
Common violations
// ❌ WRONG - Too small (24x24pt)
Button("×") {
dismiss()
}
.frame(width: 24, height: 24)
// ❌ WRONG - Small icon without padding
Image(systemName: "heart")
.font(.system(size: 16))
.onTapGesture { }
// ✅ CORRECT - Minimum 44x44pt
Button("×") {
dismiss()
}
.frame(minWidth: 44, minHeight: 44)
// ✅ CORRECT - Larger icon or padding
Image(systemName: "heart")
.font(.system(size: 24))
.frame(minWidth: 44, minHeight: 44)
.contentShape(Rectangle()) // Expand tap area
.onTapGesture { }
// ❌ WRONG - contentEdgeInsets is deprecated since iOS 15 and ignored under UIButton.Configuration
button.contentEdgeInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
// ✅ CORRECT - UIKit button with content insets via UIButton.Configuration (iOS 15+)
var config = UIButton.Configuration.plain()
config.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)
button.configuration = config
// Total size: icon size + insets ≥ 44x44ptSpacing between targets
// ❌ WRONG - Targets too close (hard to tap accurately)
HStack(spacing: 4) {
Button("Edit") { }
Button("Delete") { }
}
// ✅ CORRECT - Adequate spacing (8pt minimum, 12pt better)
HStack(spacing: 12) {
Button("Edit") { }
Button("Delete") { }
}Testing
1. Accessibility Inspector: Xcode → Open Developer Tool → Accessibility Inspector 2. Select "Audit" tab → Run audit → Check for "Small Text" and "Hit Region" warnings 3. Manual: Tap with one finger (not stylus) — can you hit it reliably without mistakes?
---
5. Keyboard Navigation (MEDIUM - iPadOS/macOS)
Problem Users who cannot use touch/mouse cannot navigate app.
WCAG 2.1.1 Keyboard (Level A - all functionality available via keyboard)
Common violations
// ❌ WRONG - Custom gesture without keyboard alternative
.onTapGesture {
showDetails()
}
// No way to trigger with keyboard
// ✅ CORRECT - Button provides keyboard support automatically
Button("Show Details") {
showDetails()
}
.keyboardShortcut("d", modifiers: .command) // Optional shortcut
// ✅ CORRECT - Custom control with focus support
struct CustomButton: View {
@FocusState private var isFocused: Bool
var body: some View {
Text("Custom")
.focusable()
.focused($isFocused)
.onKeyPress(.return) {
action()
return .handled
}
}
}Focus management
// ✅ CORRECT - Set initial focus
.focusSection() // Group related controls
.defaultFocus($focus, .constant(true)) // Set default
// ✅ CORRECT - Move focus after action
@FocusState private var focusedField: Field?
Button("Next") {
focusedField = .next
}Testing (iPadOS/macOS)
1. Connect keyboard to iPad or use Mac 2. Press Tab - does focus move to interactive elements? 3. Press Space/Return - does focused element activate? 4. Check custom controls have visible focus indicator 5. Can you reach all functionality without mouse/touch?
---
6. Reduce Motion Support (MEDIUM - Vestibular Disorders)
Problem Animations cause discomfort, nausea, or seizures for users with vestibular disorders.
WCAG 2.3.3 Animation from Interactions (Level AAA - motion animation can be disabled)
Common violations
// ❌ WRONG - Always animates (can cause nausea)
.onAppear {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
scale = 1.0
}
}
// ❌ WRONG - Parallax scrolling without opt-out
ScrollView {
GeometryReader { geo in
Image("hero")
.offset(y: geo.frame(in: .global).minY * 0.5) // Parallax
}
}
// ✅ CORRECT - Respect Reduce Motion preference
.onAppear {
if UIAccessibility.isReduceMotionEnabled {
scale = 1.0 // Instant
} else {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
scale = 1.0
}
}
}
// ✅ CORRECT - Simpler animation or cross-fade
if UIAccessibility.isReduceMotionEnabled {
// Cross-fade or instant change
withAnimation(.linear(duration: 0.2)) {
showView = true
}
} else {
// Complex spring animation
withAnimation(.spring()) {
showView = true
}
}SwiftUI modifier
// ✅ CORRECT - Automatic support
.animation(.spring(), value: isExpanded)
.transaction { transaction in
if UIAccessibility.isReduceMotionEnabled {
transaction.animation = nil // Disable animation
}
}Testing
1. Settings → Accessibility → Motion → Reduce Motion (toggle ON) 2. Navigate app - are animations reduced or eliminated? 3. Test: Transitions, scrolling effects, parallax, particle effects 4. Video autoplay should also respect this preference
---
7. Common Violations (HIGH - App Store Review)
Images Without Labels
// ❌ WRONG - Informative image without label
Image("product-photo")
// ✅ CORRECT - Informative image with label
Image("product-photo")
.accessibilityLabel("Red sneakers with white laces")
// ✅ CORRECT - Decorative image hidden
Image("background-pattern")
.accessibilityHidden(true)Buttons With Wrong Traits
// ❌ WRONG - Custom button without button trait
Text("Submit")
.onTapGesture {
submit()
}
// VoiceOver announces as "Submit, text" not "Submit, button"
// ✅ CORRECT - Use Button for button-like controls
Button("Submit") {
submit()
}
// VoiceOver announces as "Submit, button"
// ✅ CORRECT - Custom control with correct trait
Text("Submit")
.accessibilityAddTraits(.isButton)
.onTapGesture {
submit()
}Inaccessible Custom Controls
// ❌ WRONG - Custom slider without accessibility support
struct CustomSlider: View {
@Binding var value: Double
var body: some View {
// Drag gesture only, no VoiceOver support
GeometryReader { geo in
// ...
}
.gesture(DragGesture()...)
}
}
// ✅ CORRECT - Custom slider with accessibility actions
struct CustomSlider: View {
@Binding var value: Double
var body: some View {
GeometryReader { geo in
// ...
}
.gesture(DragGesture()...)
.accessibilityElement()
.accessibilityLabel("Volume")
.accessibilityValue("\(Int(value))%")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
value = min(value + 10, 100)
case .decrement:
value = max(value - 10, 0)
@unknown default:
break
}
}
}
}accessibilityAdjustableAction makes VoiceOver read the control as "adjustable" and wires single-finger swipe up/down — do NOT also reach for an .adjustable trait; SwiftUI's AccessibilityTraits has no such member (the adjustable action confers it).
Pick the Right Interaction Technique for the Control
One mechanism doesn't fit every custom control (WWDC 2026-220). Choose by shape of input, and always provide custom actions as the fallback — Switch Control and Voice Control users may not be able to perform passthrough or direct-touch gestures. (UIKit equivalents: the .allowsDirectInteraction trait plus accessibilityDirectTouchOptions (iOS 17+).)
| Control shape | Technique | API |
|---|---|---|
| Single-axis value (slider, stepper) | Adjustable action (swipe up/down) | accessibilityAdjustableAction |
| Fine-grained one-shot drag | Passthrough gesture (double-tap-and-hold, ends on release) | accessibilityActivationPoint to anchor where touches land |
| Multi-axis or named operations (2D pad) | Custom actions | accessibilityAction(named:) per direction |
| Free-form repeated gestures (drawing, virtual pet) | Direct touch (persists until focus moves) | accessibilityDirectTouch(options:) |
// Passthrough: anchor the gesture at the control's live position,
// not the default center
CoffeeSlider(value: fillLevel)
.accessibilityActivationPoint(UnitPoint(x: 0.5, y: 1 - fillLevel))
// 2D control: one named action per direction (adjustable covers only one axis)
EqualizerPad()
.accessibilityAction(named: "Move up") { increaseY(by: 10) }
.accessibilityAction(named: "Move right") { increaseX(by: 10) }
// Direct touch: raw touches go to the control, not VoiceOver.
// .requiresActivation gates it behind a double-tap;
// .silentOnTouch mutes VoiceOver for controls with their own audio
GestureSurface()
.accessibilityDirectTouch(options: [.requiresActivation])During a passthrough drag the value changes continuously, and posting an AccessibilityNotification.Announcement on every change makes VoiceOver stutter over itself. Announce only when the value actually changed AND at least 0.3 seconds have passed since the last announcement (WWDC 2026-220 uses exactly this gate).
Missing State Announcements
// ❌ WRONG - State change without announcement
Button("Toggle") {
isOn.toggle()
}
// ✅ CORRECT - State change with announcement
Button("Toggle") {
isOn.toggle()
UIAccessibility.post(
notification: .announcement,
argument: isOn ? "Enabled" : "Disabled"
)
}
// ✅ CORRECT - Automatic state with accessibilityValue
Button("Toggle") {
isOn.toggle()
}
.accessibilityValue(isOn ? "Enabled" : "Disabled")Choose the Right Notification (Don't Use .announcement for Everything)
VoiceOver has three distinct notifications. Using .announcement for new content that arrives on screen leaves focus stranded on the old element — the new content is announced but the user can't navigate to it. Match the notification to what changed.
| Notification | Use when | Argument | Effect |
|---|---|---|---|
.announcement | Discrete event with no new focusable target (score update, save complete, error toast) | The string to speak | Speaks, focus unchanged |
.layoutChanged | New content appeared in place (search results, expanded section, validation error) | The element to focus (or nil) | Speaks + moves focus to the passed element |
.screenChanged | Whole screen replaced (push/pop, sheet, tab switch) | The element to focus first (or nil) | Plays screen-change tone, refocuses, re-reads layout |
// ❌ WRONG - new results announced but focus stuck on the search field
UIAccessibility.post(notification: .announcement, argument: "12 results found")
// ✅ CORRECT - SwiftUI: discrete event, focus unchanged
AccessibilityNotification.Announcement("Saved").post()
// ✅ CORRECT - SwiftUI: new content arrived, move focus to it
// (@AccessibilityFocusState binding + LayoutChanged)
resultsFocused = true
AccessibilityNotification.LayoutChanged().post()
// ✅ CORRECT - UIKit: new content arrived, move focus to it
UIAccessibility.post(notification: .layoutChanged, argument: firstResultCell)
// ✅ CORRECT - whole-screen replacement
UIAccessibility.post(notification: .screenChanged, argument: detailTitleLabel)For announcements that must not be interrupted, set priority on the announcement string — there is no view modifier for this. In SwiftUI set the accessibilitySpeechAnnouncementPriority AttributedString attribute and post that string; in UIKit post an NSAttributedString carrying the same attribute (.high cannot be interrupted, .low is queued) so the message isn't dropped by VoiceOver's queue.
var message = AttributedString("Connection lost")
message.accessibilitySpeechAnnouncementPriority = .high
AccessibilityNotification.Announcement(message).post()8. Assistive Access Support (Cognitive Disabilities)
Problem App is unavailable or broken in Assistive Access mode, excluding users with cognitive disabilities who rely on a simplified system experience.
Assistive Access is a system-wide mode (Settings > Accessibility > Assistive Access) that replaces the standard iOS UI with large controls, simplified navigation, and reduced cognitive load. Apps that don't opt in are hidden from users in this mode.
Availability splits by API The Assistive Access mode and its Info.plist opt-in keys (UISupportsAssistiveAccess, UISupportsFullScreenInAssistiveAccess) are iOS 17+. The newer programmatic APIs arrived later: @Environment(\.accessibilityAssistiveAccessEnabled) is iOS 18.0+ (macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0), and the SwiftUI AssistiveAccess scene, assistiveAccessNavigationIcon(_:), and the UIKit .windowAssistiveAccessApplication scene-session-role are all iOS 26.0+.
Symptom: App missing from Assistive Access home screen
Your app doesn't appear under "Optimized Apps" in Assistive Access settings.
<!-- ✅ FIX - Add to Info.plist -->
<key>UISupportsAssistiveAccess</key>
<true/>This makes the app available and launches it full screen in Assistive Access mode. Without this key, users in Assistive Access mode cannot access your app at all.
Symptom: Standard UI too complex for Assistive Access users
Your app launches in Assistive Access but shows the full standard interface, overwhelming users who need simplified controls.
// ✅ FIX - Provide a dedicated Assistive Access scene (iOS 26.0+)
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView() // Standard UI
}
// AssistiveAccess scene is @available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *)
AssistiveAccess {
AssistiveAccessContentView() // Simplified UI
}
}
}The AssistiveAccess scene type (iOS 26.0+) provides a separate entry point. When the system is in Assistive Access mode, it uses this scene instead of the standard WindowGroup. Native SwiftUI controls inside this scene automatically adopt the Assistive Access visual style (large buttons, prominent navigation, grid/row layout).
Symptom: App already designed for cognitive accessibility but displays in reduced frame
If your app is already purpose-built for users with cognitive disabilities (e.g., AAC apps), it may appear in a reduced frame rather than full screen.
<!-- ✅ FIX - Add to Info.plist for apps already designed for cognitive accessibility -->
<key>UISupportsFullScreenInAssistiveAccess</key>
<true/>This displays your app identically to its standard appearance, bypassing the Assistive Access frame.
Detecting Assistive Access at runtime (iOS 18.0+)
Runtime detection via this environment value requires iOS 18.0+ (macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0) — it is unavailable on iOS 17, where the Assistive Access mode itself first shipped.
struct MyView: View {
// @Environment(\.accessibilityAssistiveAccessEnabled) is iOS 18.0+
@Environment(\.accessibilityAssistiveAccessEnabled) var assistiveAccessEnabled
var body: some View {
if assistiveAccessEnabled {
// Simplified content
} else {
// Standard content
}
}
}UIKit implementation (iOS 26.0+)
For UIKit apps, use the .windowAssistiveAccessApplication scene session role (UIWindowSceneSessionRoleAssistiveAccessApplication, iOS 26.0+ / tvOS 26.0+ / visionOS 26.0+, unavailable on watchOS and macOS) in your UISceneConfiguration to route to a dedicated scene delegate for the Assistive Access experience.
Design principles for Assistive Access scenes
- Distill to core functionality — One or two essential features, not the full app
- Large, prominent controls — Ample spacing, no hidden gestures or timed interactions
- Multiple representations — Pair text with icons; use visual alternatives
- Step-by-step navigation — Clear back buttons, consistent patterns
- Safe interactions — Remove irreversible actions; confirm destructive ones
Adding navigation icons (iOS 26.0+)
NavigationStack {
MyView()
.navigationTitle("My Feature")
// assistiveAccessNavigationIcon is iOS 26.0+ (macOS/tvOS/watchOS/visionOS 26.0+)
.assistiveAccessNavigationIcon(systemImage: "star.fill")
}Testing
1. Device — Enable Assistive Access in Settings > Accessibility > Assistive Access, verify app appears in "Optimized Apps", test the full user flow 2. Accessibility Inspector — Run audit on the Assistive Access scene for label, contrast, and hit region issues
---
9. Continuous Reading & Text Navigation (Long-Form Reading Apps)
Problem In reading apps (books, articles, scanned documents), VoiceOver text navigation stops dead at paragraph or page boundaries, and Speak Screen's read-all halts at the bottom of each page — users must swipe manually mid-chapter.
Techniques from WWDC 2026-219. Properly structured text content also makes the system Accessibility Reader experience better (iOS 26).
Symptom: VoiceOver can't move past the end of a paragraph
Separate text elements read as islands. Link them so character/word/line navigation continues seamlessly across the gap.
// UIKit (iOS 18+): chain elements in both directions
func configureNavigationElements() {
for (index, paragraph) in paragraphs.enumerated() {
if index + 1 < paragraphs.count {
paragraph.accessibilityNextTextNavigationElement = paragraphs[index + 1]
}
if index > 0 {
paragraph.accessibilityPreviousTextNavigationElement = paragraphs[index - 1]
}
}
}// SwiftUI: link selectable text elements with a shared id + namespace
struct PageView: View {
@Namespace private var pageNamespace
let paragraphs: [String]
let pageNumber: Int
var body: some View {
Text(paragraphs[0])
.textSelection(.enabled)
.accessibilityLinkedGroup(id: pageNumber, in: pageNamespace)
Text(paragraphs[1])
.textSelection(.enabled)
.accessibilityLinkedGroup(id: pageNumber, in: pageNamespace)
}
}The accessibilityLinkedGroup(id:in:) modifier itself long predates this — starting in iOS 27, linking selectable text elements this way gives VoiceOver continuous text navigation across them. UIKit also offers block variants (accessibilityNextTextNavigationElementBlock/accessibilityPreviousTextNavigationElementBlock, iOS 18+) for lazily resolved elements. On macOS, use AppKit's long-standing accessibilitySharedTextUIElements property (NSAccessibility), which backs the AX attribute AXSharedTextUIElements.
Symptom: Read-all (Speak Screen / VoiceOver) stops at each page
Mark the last element with .causesPageTurn and implement accessibilityScroll so assistive technologies advance pages themselves — the audiobook experience:
override func viewDidLoad() {
super.viewDidLoad()
lastParagraphView.accessibilityTraits.insert(.causesPageTurn)
}
override func accessibilityScroll(_ direction: UIAccessibilityScrollDirection) -> Bool {
moveToPage(direction)
UIAccessibility.post(notification: .pageScrolled,
argument: "Page \(currentPage) of \(pageCount)")
return true
}Symptom: Editing actions buried for VoiceOver users
Put contextual actions (highlight, save, bookmark) on the editor rotor with the edit category (iOS 18+):
override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
get {
let save = UIAccessibilityCustomAction(name: "Save Recommendation") { _ in
self.saveRecommendation()
return true
}
save.category = UIAccessibilityCustomAction.editCategory
return (super.accessibilityCustomActions ?? []) + [save]
}
set { }
}Symptom: Custom-rendered text (scanned pages, custom engines) is invisible to assistive tech
Adopt UITextInput on the view — implement the protocol in its entirety, including tokenizer-driven granularity (UITextInputStringTokenizer) and UITextInputDelegate selection notifications, and VoiceOver and Speak Screen work without a UITextView. UITextInteraction(for: .nonEditable) is optional polish on top: it adds the system selection UI (handles, highlight), not the accessibility behavior.
---
10. Captions & Subtitle Styling (Video Playback)
Two accessibility features for video players. Generated subtitles are automatic in the 27 cycle — your only job is to expose subtitle selection so users can reach them. Subtitle style preview shipped in iOS 26.4 and lets users restyle captions without leaving your app. Both come from WWDC 2026-256.
Generated Subtitles OS27
When content lacks a subtitle language the viewer understands, the system creates subtitles live and on-device during video playback — speech transcription (English subtitles from English audio: iOS, macOS, tvOS, visionOS 27) and language translation (other languages from English subtitles: iOS, macOS 27). Authored subtitles are always preferred and left unchanged; generated tracks are marked with a sparkle and "Translated."
You implement nothing to turn this on — it is automatic during playback for HTTP Live Streaming (live and on-demand) and file-based content. The one thing you must do is give users a way to select a subtitle track, or generated subtitles stay invisible to the people who need them most:
AVPlayerViewController(iOS) andAVPlayerView(macOS) provide full subtitle selection (and player controls) for free.AVLegibleMediaOptionsMenuControlleradds subtitle-selection UI to an existing custom player (iOS/macOS/visionOS 26.4 — not tvOS).- Or build custom media-selection controls that match your player.
Symptom: generated subtitles never appear for the user
Almost always a missing or broken subtitle-selection UI. Verify the user can reach a Subtitles menu in your player; a custom player with no media-option selection gives generated tracks nowhere to surface.
Subtitle Style Preview (iOS 26.4+)
Users have long been able to pick and customize caption styling (font, color, border) in Settings. The style preview lets them do it live, inside your player, with a real preview — far more accessible than sending them to Settings mid-video.
AVPlayerViewController/AVPlayerView implement the whole preview for free. To add it to a custom player UI, use AVLegibleMediaOptionsMenuController. To drive it yourself from an AVPlayerLayer:
import AVFoundation
import MediaAccessibility
@available(iOS 26.4, macOS 26.4, tvOS 26.4, visionOS 26.4, *)
final class SubtitleStyleController {
let playerLayer: AVPlayerLayer
var profileIDs: [String] = []
init(playerLayer: AVPlayerLayer) { self.playerLayer = playerLayer }
// Each system caption style has a MACaptionAppearance profile ID.
func loadStyleProfiles() {
profileIDs = MACaptionAppearanceCopyProfileIDs() as? [String] ?? []
}
// Preview a style live. New subtitles render in it; any active subtitles are
// auto-hidden so they don't interfere. text: nil shows localized placeholder
// text. position is an offset from the default location — pass a non-zero
// value to keep the preview clear of your playback controls (.zero = default).
func previewStyle(_ profileID: String, offset: CGPoint = .zero) {
playerLayer.setCaptionPreviewProfileID(profileID, position: offset, text: nil)
}
// ALWAYS stop the preview when the user is done — this removes the placeholder
// and restores the active subtitles.
func endPreview() {
playerLayer.stopShowingCaptionPreview()
}
// Commit the chosen style; it applies to all subtitles, system-wide.
func applyStyle(_ profileID: String) {
MACaptionAppearanceSetActiveProfileID(profileID as CFString)
}
}Rendering captions entirely yourself? AVCaptionRenderer.captionPreview(forProfileID:extendedLanguageTag:renderSize:) returns a styled NSAttributedString for a profile ID — but Apple warns it can block, so generate previews off the main thread.
Common mistakes
| Mistake | Result | Fix |
|---|---|---|
| Custom player with no subtitle-selection UI | Generated subtitles (OS27) never reach users | Adopt AVPlayerViewController/AVPlayerView, or add AVLegibleMediaOptionsMenuController |
Forgetting stopShowingCaptionPreview() | The placeholder sticks and the real subtitles stay hidden | Always end the preview when selection finishes |
Calling captionPreview(forProfileID:…) on the main thread | UI hitch while it renders | Generate previews off-main |
| Reaching for the menu controller on tvOS | AVLegibleMediaOptionsMenuController is unavailable there | Use AVPlayerViewController's built-in subtitle UI on tvOS |
---
Accessibility Inspector Workflow
1. Launch Accessibility Inspector
Xcode → Open Developer Tool → Accessibility Inspector
2. Select Target
- Dropdown: Choose running simulator or connected device
- Target: Select your app
3. Inspection Mode
- Click "Inspection Pointer" button (crosshair icon)
- Hover over UI elements to see:
- Label, Value, Hint, Traits
- Frame, Path
- Actions available
- Parent/child hierarchy
4. Run Audit
- Click "Audit" tab
- Click "Run Audit" button
- Review findings:
- Contrast — Color contrast issues
- Hit Region — Touch target size issues
- Clipped Text — Text truncation with Dynamic Type
- Element Description — Missing labels/hints
- Traits — Wrong accessibility traits
5. Fix and Re-Test
- Click each finding for details
- Fix in code
- Re-run audit to verify
VoiceOver Testing Checklist
Enable VoiceOver
- Simulator Cmd+F5 or Settings → Accessibility → VoiceOver
- Device Triple-click side button (if enabled in Settings)
Navigation Testing
1. ☐ Swipe right/left - moves logically through UI elements 2. ☐ Each element announces purpose clearly 3. ☐ No unlabeled elements (except decorative) 4. ☐ Heading navigation works (swipe up/down with 2 fingers) 5. ☐ Container navigation works (swipe left/right with 3 fingers)
Interaction Testing
1. ☐ Double-tap activates buttons 2. ☐ Swipe up/down adjusts sliders/pickers (with .accessibilityAdjustableAction) 3. ☐ Custom gestures have VoiceOver equivalents 4. ☐ Text fields announce keyboard type 5. ☐ State changes are announced
Content Testing
1. ☐ Images have descriptive labels or are hidden 2. ☐ Error messages are announced 3. ☐ Loading states are announced 4. ☐ Modal sheets announce role 5. ☐ Alerts announce automatically
App Store Review Preparation
Required Accessibility Features (iOS)
1. VoiceOver Support
- All UI elements must have labels
- Navigation must be logical
- All actions must be performable
2. Dynamic Type
- Text must scale from -3 to +12 sizes
- Layout must adapt without clipping
3. Sufficient Contrast
- Minimum 4.5:1 for normal text
- Minimum 3:1 for large text (≥18pt)
App Store Connect Metadata
Accessibility Nutrition Labels — declare the accessibility features your app supports (VoiceOver, Larger Text, Sufficient Contrast, Reduced Motion, and more) on your App Store product page; users who need accessible apps look for them. Only declare what you've actually tested. Larger Text is declarable for tvOS apps once they support tvOS 27's Large Text setting (Section 2).
When submitting: 1. Accessibility → Select features your app supports (Nutrition Labels taxonomy):
- ☑ VoiceOver
- ☑ Larger Text (Dynamic Type)
- ☑ Sufficient Contrast
- ☑ Reduced Motion
2. Test Notes: Document accessibility testing
Accessibility Testing Completed:
- VoiceOver: All screens tested with VoiceOver enabled
- Dynamic Type: Tested at all size categories
- Color Contrast: Verified 4.5:1 minimum contrast
- Touch Targets: All buttons minimum 44x44pt
- Reduce Motion: Animations respect user preferenceCommon Rejection Reasons
1. "App is not fully functional with VoiceOver"
- Missing labels on images/buttons
- Unlabeled custom controls
- Actions not performable with VoiceOver
2. "Text is not readable at all Dynamic Type sizes"
- Fixed font sizes
- Text clipping at large sizes
- Layout breaks at accessibility sizes
3. "Insufficient color contrast"
- Text fails 4.5:1 ratio
- UI elements fail 3:1 ratio
- Color-only indicators
---
Design Review Pressure: Defending Accessibility Requirements
The Problem
Under design review pressure, you'll face requests to:
- "Those VoiceOver labels make the code messy - can we skip them?"
- "Dynamic Type breaks our carefully designed layout - let's lock font sizes"
- "The high contrast requirement ruins our brand aesthetic"
- "44pt touch targets are too big - make them smaller for a cleaner look"
These sound like reasonable design preferences. But they violate App Store requirements and exclude 15% of users. Your job: defend using App Store guidelines and legal requirements, not opinion.
Red Flags — Designer Requests That Violate Accessibility
If you hear ANY of these, STOP and reference this skill:
- ❌ "Skip VoiceOver labels on icon-only buttons" – App Store rejection (Guideline 2.5.1)
- ❌ "Use fixed 14pt font for compact design" – Excludes users with vision disabilities
- ❌ "3:1 contrast ratio is fine" – Fails WCAG AA for text (needs 4.5:1)
- ❌ "Make buttons 36x36pt for clean aesthetic" – Fails touch target requirement (44x44pt minimum)
- ❌ "Disable Dynamic Type in this screen" – App Store rejection risk
- ❌ "Color-code without labels (red=error, green=success)" – Excludes colorblind users (8% of men)
Implementation Traps (Your Own Code, Not the Designer)
These pass a quick glance but fail real VoiceOver / Dynamic Type use:
- ❌ `.announcement` for content that arrived on screen – Speaks it but strands focus. Use
.layoutChanged(or.screenChanged) and pass the new element. See the notification taxonomy above. - ❌ Eyeballing contrast ("looks readable") – Light gray fails at ~2.8:1. Measure with the Accessibility Inspector Audit;
#767676is the lightest gray that passes AA body text. - ❌ Fixing fonts but leaving fixed `height`/`lineLimit(1)` – Text scales then clips. The clip is the frame:
lineLimit(nil)+.fixedSize(horizontal: false, vertical: true), and reflow HStack → VStack viadynamicTypeSize.isAccessibilitySize.
How to Push Back Professionally
Step 1: Show the Guideline
"I want to support this design direction, but let me show you Apple's App Store
Review Guideline 2.5.1:
'Apps should support accessibility features such as VoiceOver and Dynamic Type.
Failure to include sufficient accessibility features may result in rejection.'
Here's what we need for approval:
1. VoiceOver labels on all interactive elements
2. Dynamic Type support (can't lock font sizes)
3. 4.5:1 contrast ratio for text, 3:1 for UI
4. 44x44pt minimum touch targets
Let me show where our design currently falls short..."Step 2: Demonstrate the Risk
Open the app with accessibility features enabled:
- VoiceOver (Cmd+F5): Show buttons announcing "Button" instead of purpose
- Largest Text Size: Show layout breaking or text clipping
- Color Contrast Analyzer: Show failing contrast ratios
- Touch target overlay: Show targets < 44pt
Reference
- App Store Review Guideline 2.5.1
- WCAG 2.1 Level AA (industry standard)
- ADA compliance requirements (legal risk in US)
Step 3: Offer Compromise
"I can achieve your aesthetic goals while meeting accessibility requirements:
1. VoiceOver labels: Add them programmatically (invisible in UI, required for approval)
2. Dynamic Type: Use layout techniques that adapt (examples from Apple HIG)
3. Contrast: Adjust colors slightly to meet 4.5:1 (I'll show options that preserve brand)
4. Touch targets: Expand hit areas programmatically (visual size stays the same)
These changes won't affect the visual design you're seeing, but they're required
for App Store approval and legal compliance."Step 4: Document the Decision
If overruled (designer insists on violations):
Slack message to PM + designer:
"Design review decided to proceed with:
- Fixed font sizes (disabling Dynamic Type)
- 38x38pt buttons (below 44pt requirement)
- 3.8:1 text contrast (below 4.5:1 requirement)
Important: These changes violate App Store Review Guideline 2.5.1 and WCAG AA.
This creates three risks:
1. App Store rejection during review (adds 1-2 week delay)
2. ADA compliance issues if user files complaint (legal risk)
3. 15% of potential users unable to use app effectively
I'm flagging this proactively so we can prepare a response plan if rejected."Why this works
- You're not questioning their design taste
- You're raising App Store rejection risk (business impact)
- You're citing specific guidelines (not opinion)
- You're offering solutions that preserve visual design
- You're documenting the decision (protects you post-rejection)
Real-World Example: App Store Rejection (48-Hour Resubmit Window)
Scenario
- 48 hours until resubmit deadline after rejection
- Apple cited: "2.5.1 - Insufficient VoiceOver support"
- Designer says: "Just add generic labels quickly"
- PM watching the meeting, wants fastest fix
What to do
// ❌ WRONG - Generic labels (will fail re-review)
Button(action: addToCart) {
Image(systemName: "cart.badge.plus")
}
.accessibilityLabel("Button") // Apple will reject again
// ✅ CORRECT - Descriptive labels (passes review)
Button(action: addToCart) {
Image(systemName: "cart.badge.plus")
}
.accessibilityLabel("Add to cart")
.accessibilityHint("Double-tap to add this item to your shopping cart")In the meeting, demonstrate
1. Enable VoiceOver (Cmd+F5) 2. Show "Button" announcement (generic - fails) 3. Show "Add to cart" announcement (descriptive - passes) 4. Reference Apple's rejection message: "Elements must have descriptive labels"
Time estimate 2-4 hours to audit all interactive elements and add proper labels.
Result
- Honest time estimate prevents second rejection
- Proper labels pass Apple review
- Resubmit accepted within 48 hours
When to Accept the Design Decision (Even If You Disagree)
Sometimes designers have valid reasons to override accessibility guidelines. Accept if:
- [ ] They understand the App Store rejection risk
- [ ] They're willing to delay launch if rejected
- [ ] You document the decision in writing
- [ ] They commit to fixing if rejected
Document in Slack
"Design review decided to proceed with [specific violations].
We understand this creates:
- App Store rejection risk (Guideline 2.5.1)
- Potential 1-2 week delay if rejected
- Need to audit and fix all instances if rejected
Monitoring plan:
- Submit for review with current design
- If rejected, implement proper accessibility (estimated 2-4 hours)
- Have accessibility-compliant version ready as backup"This protects both of you and shows you're not blocking - just de-risking.
---
WCAG Compliance Levels
Level A (Minimum — Required for App Store)
- 1.1.1 Non-text Content — Images have text alternatives
- 2.1.1 Keyboard — All functionality via keyboard (iPadOS/macOS)
- 4.1.2 Name, Role, Value — Elements have accessible names
Level AA (Standard — Recommended)
- 1.4.3 Contrast (Minimum) — 4.5:1 text, 3:1 UI
- 1.4.4 Resize Text — Support 200% text scaling
- 1.4.5 Images of Text — Use real text when possible
Level AAA (Enhanced — Best Practice)
- 1.4.6 Contrast (Enhanced) — 7:1 text, 4.5:1 UI
- 2.3.3 Animation from Interactions — Reduce Motion support
- 2.5.5 Target Size - 44x44pt minimum targets
Goal Meet Level AA for all content, Level AAA where feasible.
Quick Command Reference
After making fixes:
# Quick scan for new issues
/axiom:audit accessibilityResources
WWDC: 2026-219, 2026-220, 2026-221, 2026-256
Docs: /accessibility/voiceover, /uikit/uifont/scaling_fonts_automatically, /uikit/uiaccessibilityreadingcontent, /swiftui/view/accessibilitylinkedgroup(id:in:), /avfoundation/avplayerlayer, /avkit/avlegiblemediaoptionsmenucontroller, /mediaaccessibility
---
Remember Accessibility is not a feature, it's a requirement. 15% of users have some form of disability. Making your app accessible isn't just the right thing to do - it expands your user base and improves the experience for everyone.
UX Flow Audit
UX issues are not polish — they're defects that cause support tickets, bad reviews, and user churn.
Axiom's code-level auditors check patterns. This skill checks what users actually experience: Can they complete their task? Can they get back? Do they know what's happening?
6 iOS UX Principles (Detection Anchors)
These principles anchor every detection category. When a principle is violated, users get stuck, confused, or frustrated.
1. Honor the Promise
What the button/title says must match what the user gets. A "Settings" button that opens a profile page breaks trust.
2. Escape Hatch
Every modal (sheet, fullScreenCover, alert) must have a way out. A sheet without a dismiss button or drag-to-dismiss traps users.
3. Primary Action Visibility
The main thing users came to do must be immediately visible and tappable. If the CTA requires scrolling or menu-diving, users won't find it.
4. Dead End Prevention
Every view must have a forward path (next step) or a completion state (success message, return to start). A view with no actions and no navigation is a dead end.
5. Progressive Disclosure
Don't overwhelm on first screen. Show essentials first, details on demand. An onboarding flow that dumps 12 settings on page one loses users.
6. Feedback Loop
Users must know what's happening during async operations. No loading state = "is it broken?" No error state = "what went wrong?" No empty state = "is this feature missing?"
Detection Categories
8 Core Defects (always check — these are UX bugs, not opinions):
1. Dead-End Views (CRITICAL) 2. Dismiss Traps (CRITICAL) 3. Buried CTAs (HIGH) 4. Promise-Scope Mismatch (HIGH) 5. Deep Link Dead Ends (HIGH) 6. Missing Empty States (HIGH) 7. Missing Loading/Error States (HIGH) 8. Accessibility Dead Ends (HIGH)
3 Contextual Checks (check when product context warrants — these involve design judgment):
9. Onboarding Gaps (MEDIUM) — requires knowing the product's onboarding strategy 10. Broken Data Paths (MEDIUM) — overlaps with code correctness; include only when UX-visible 11. Platform Parity Gaps (MEDIUM) — depends on target device strategy
Core defects are always worth reporting. Contextual checks require product knowledge — flag them if they look wrong, but acknowledge they may be intentional decisions.
1. Dead-End Views (CRITICAL)
Views with no navigation forward, no actions, and no completion state.
Detect:
- SwiftUI: Views with no
NavigationLink,Button,.sheet,.fullScreenCover,.navigationDestination, or dismiss action - UIKit: View controllers with no
IBAction, noaddTarget, no navigation push/present calls, noUIBarButtonItem - Check for views/VCs that are navigation destinations but offer no way to proceed or return
Common cause: Placeholder views during development that ship to production.
2. Dismiss Traps (CRITICAL)
Sheets or fullScreenCover without a dismiss path.
Detect:
- SwiftUI:
.fullScreenCoverwithout@Environment(\.dismiss)or explicit dismiss button;.sheetwith.interactiveDismissDisabled(true)without alternative dismiss; alert/confirmation dialogs missing cancel actions - UIKit:
present(_:animated:)withmodalPresentationStyle = .fullScreenwhere presented VC has no dismiss/close button;isModalInPresentation = truewithout alternative dismiss path
Why critical: Users literally cannot leave the screen. The only escape is force-quitting the app.
3. Buried CTAs (HIGH)
Primary actions hidden below fold, in menus, or behind navigation.
Detect:
- Primary action buttons placed after long
ScrollViewcontent - Important actions only in
.toolbaroverflow menu (.secondaryAction) - CTAs inside expandable
DisclosureGroupsections - No prominent action on the main tab's root view
Not a buried CTA: Below-fold placement that is intentional — checkout confirmation ("review order then confirm"), terms acceptance ("read then agree"), or content that the user should see before acting. The test: is the below-fold placement serving the user (they need context first) or hurting them (they can't find the action)?
4. Promise-Scope Mismatch (HIGH)
NavigationTitle, button label, or tab name doesn't match the content.
Detect:
.navigationTitle("X")where view content is clearly about YNavigationLink("Settings")that navigates to a profile/account view- Tab labels that don't match tab content
- Button text suggesting one action but performing another
5. Deep Link Dead Ends (HIGH)
URL opens but lands on empty or broken state.
Detect:
.onOpenURLhandlers that push a view without checking if data exists- Deep link destinations that assume pre-loaded state
- Universal link handling that doesn't validate the entity ID
- No fallback when deep-linked content is unavailable
Cross-reference: axiom-swiftui (navigation reference) covers deep link architecture. This category checks the UX outcome.
6. Missing Empty States (HIGH)
Lists, grids, or content views with no data show blank screen.
Detect:
ListorForEachwithoutif items.isEmpty { ... }or.overlayfor empty state@Queryresults displayed without empty check- Search results with no "no results" view
- Filtered views that can reach zero items
7. Missing Loading/Error States (HIGH)
Async operations with no feedback.
Detect:
- SwiftUI:
.task { }orTask { }that fetches data without a loading indicator;try awaitwithout error presentation (no.alert, no error state variable); state enum missing.loadingor.errorcases - UIKit:
URLSessioncalls withoutUIActivityIndicatorViewor progress UI; completion handlers that don't update UI on error; missingUIAlertControllerfor failure cases - Both: Network calls without timeout or retry UI
- Both:
catchblocks that onlyprint/log in#if DEBUGwith no user-visible feedback — the user sees the operation silently fail
Focus on network/write operations: Skip loading indicators for fast local reads (GRDB queries, UserDefaults, cached data) that complete in under 100ms — adding spinners to these creates visual flicker. Focus on network calls, database writes, and any operation that can meaningfully fail.
Scan systematically: When you find a silent-error pattern in one file (e.g., catch { print(...) } without user feedback), scan ALL similar files for the same pattern. A single catch-block issue usually indicates a codebase-wide habit.
8. Accessibility Dead Ends (HIGH)
Actions only reachable via gestures or visual cues, invisible to assistive technology.
Detect:
.onLongPressGesture/.swipeActions/DragGesturewithout.accessibilityActionequivalent- Custom controls without
.accessibilityLabelor.accessibilityHint - Navigation that depends on color alone (e.g., "tap the green button")
- Pull-to-refresh (
refreshable) without VoiceOver-accessible alternative (note:refreshableis automatically accessible — check custom implementations)
Cross-reference: skills/accessibility-diag.md covers full WCAG compliance. This category specifically checks UX flow reachability from assistive technology.
9. Onboarding Gaps (MEDIUM)
First-launch flow that's incomplete or overwhelming.
Detect:
- No
@AppStorage-gated onboarding check - Onboarding flow without skip/later option
- More than 5 onboarding screens
- Onboarding that requires account creation before showing app value
10. Broken Data Paths (MEDIUM)
State/binding wiring issues that manifest as UX problems (view shows stale data, edits don't save, view appears empty when data exists).
Detect:
- Views accepting
@Bindingthat are initialized with.constant()in non-preview code - Views expecting
@Environmentvalues not provided by ancestors @Observablemodels created locally when they should be injected@Stateused where@Bindingshould propagate changes upward
Scope note: This overlaps with general SwiftUI correctness (axiom-swiftui, debugging reference). Include findings here only when the broken data path causes a visible UX problem — blank screen, stale content, edits that don't persist. Skip compiler-level or crash-level issues that belong in code review.
11. Platform Parity Gaps (MEDIUM)
iPad sidebar missing, landscape broken, Mac Catalyst issues.
Detect:
NavigationStackwithoutNavigationSplitViewalternative for iPad- No
.horizontalSizeClasschecks for adaptive layout - Views that break in landscape (fixed heights, no scroll)
- Missing keyboard shortcut support on iPad/Mac
Audit Process
Step 1: Map Entry Points
Find all ways users enter the app:
@mainApp struct / SceneDelegate.onOpenURLhandlers (deep links)- Widget
Linkdestinations - Notification response handlers (
UNUserNotificationCenterDelegate) - Spotlight/Siri intent handlers
Step 2: Map Navigation Containers
Find all navigation structure:
NavigationStack/NavigationSplitViewTabViewwith tab structure.sheet/.fullScreenCoverpresentations- Custom modal presentations
Step 3: Trace Flows
For each entry point → completion path: 1. Can the user reach their goal? 2. Can the user get back? 3. Does the user know what's happening at each step?
Step 4: Check Data Wiring
- Are
@Bindingvars actually passed from parent? - Are
@Observableobjects injected via environment? - Are
@Queryresults handled for empty case?
Step 5: Check Platform Adaptivity
- iPad: Does sidebar/split view work?
- Landscape: Does layout adapt?
- Mac Catalyst/Designed for iPad: Do keyboard shortcuts exist?
Step 6: Check Accessibility Flows
- Can VoiceOver users complete every flow?
- Are gesture-only actions backed by accessibility actions?
Cross-Auditor Correlation
When findings overlap with other Axiom auditors, note the correlation and elevate severity:
| UX Finding | Overlapping Auditor | Compound Effect | Severity Bump |
|---|---|---|---|
| Dead end + missing NavigationPath | swiftui-nav-auditor | Programmatic fix impossible | CRITICAL |
Gesture-only action + no .accessibilityAction | accessibility-auditor | Dead end for VoiceOver users | CRITICAL |
| Missing loading state + unhandled async error | concurrency-auditor | Crash + no user feedback | CRITICAL |
| Missing empty state + @Query with no results | swiftdata-auditor | Blank screen after data migration | HIGH |
| Deep link dead end + no URL validation | swiftui-nav-auditor | Silent failure from external link | HIGH |
Output Format
Enhanced Rating Table (for CRITICAL and HIGH findings)
| Finding | Urgency | Blast Radius | Fix Effort | ROI |
|---|---|---|---|---|
| Dead-end after payment | Ship-blocker | All users | 30 min | Critical |
| Missing empty state on search | Next release | Users who search | 15 min | High |
Urgency: Ship-blocker / Next release / Backlog Blast Radius: All users / Specific flow / Edge case Fix Effort: Time estimate for the fix ROI: Computed from urgency x blast radius / effort
Navigation Reachability Score
At end of audit, output:
## Navigation Reachability
- Total screens found: [N] (views with navigation presentation)
- Deep-linkable screens: [N] (.onOpenURL can reach them)
- Widget-reachable screens: [N] (widget Link destinations)
- Notification-reachable screens: [N] (notification handlers)
- Coverage: [N]% of screens are externally reachableFix Effort Reality Check
Most UX flow defects are fast fixes. When someone says "that's a big change," check this table:
| Defect | Typical Fix | Time |
|---|---|---|
| Dismiss trap (no close button) | Add toolbar Cancel button + dismiss() | 10-15 min |
| Missing empty state | Add if items.isEmpty { ContentUnavailableView(...) } | 15-20 min |
| Buried CTA (placement change) | Move button from .secondaryAction to .primaryAction | 20-30 min |
| Dead-end view (no forward path) | Add NavigationLink or action button | 15-30 min |
| Missing loading state | Add @State var isLoading + ProgressView overlay | 15-20 min |
| Silent error (no user feedback) | Add .alert presentation on catch block | 10-15 min |
| Gesture-only action | Add .accessibilityAction + visible button alternative | 15-20 min |
The cost of NOT fixing: A dismiss trap or dead end after payment generates 1-star reviews within hours of launch. Each review costs 10-20 positive reviews to offset. The 15-minute fix prevents weeks of damage control.
Anti-Rationalization
| Thought | Reality |
|---|---|
| "UX issues are just polish, we'll fix later" | UX dead ends cause 1-star reviews. They're defects, not enhancements. A 15-min fix now prevents weeks of damage control. |
| "Users will figure it out" | Users don't figure it out. They delete the app. Average user tries for 30 seconds. |
| "We'll add empty states after launch" | Empty states are the FIRST thing new users see. Launching without them means launching broken. |
| "That fix is a big design change" | Most UX fixes are placement or state changes (10-30 min). Check the Fix Effort table above. |
| "Accessibility is a separate concern" | If VoiceOver users can't complete a flow, it's a dead end. Same defect, different user. |
| "This screen is just temporary" | Temporary screens ship. Check them anyway. |
| "The dismiss gesture handles it" | fullScreenCover has no dismiss gesture. That's the trap. |
Resources
Skills: axiom-swiftui, skills/accessibility-diag.md, axiom-design (skills/hig.md)
Agents: ux-flow-auditor (automated scanning), swiftui-nav-auditor (navigation architecture), accessibility-auditor (WCAG compliance)
Accessibility on watchOS
When to Use This Skill
Use when:
- Auditing a watchOS app for VoiceOver, AssistiveTouch, or Double Tap support
- Implementing Dynamic Type on watch faces, watch UI, complications, or notifications
- Making a custom watch control (counter, scrubber, picker) VoiceOver-adjustable
- Fixing AssistiveTouch cursor frames that clip or miss tappable elements
- Supporting the large accessibility text sizes introduced in watchOS 8
- Debugging why a view element isn't focusable via AssistiveTouch
Related Skills
- Use the rest of
axiom-accessibilityfor general (cross-platform) VoiceOver, Dynamic Type, and contrast guidance — this skill covers watchOS-specific additions - Use
axiom-watchos/skills/design-for-watchos.mdfor the watchOS 10 navigation model and Always-On design - Use
axiom-watchos/skills/smart-stack-and-complications.mdfor complication surfaces that need accessibility labels - Use
axiom-watchos/skills/controls-and-live-activities.mdfor control surfaces and the Double Tap primary action
Core Principle
Three assistive technologies ship on Apple Watch: VoiceOver, AssistiveTouch, and Double Tap. Each has a distinct input model and SwiftUI support surface. An accessible watchOS app addresses all three, plus Dynamic Type at the large accessibility sizes that watchOS 8 introduced. Most of what works on iOS carries over — what follows is the watchOS-specific additions and pressure points.
"Accessibility is about people using their devices in the way that's best for them. And that means, to give your app the best user experience, accessibility must be considered." — Daniel Sykes-Turner, Apple Accessibility
Watch-Specific Assistive Technologies
| Technology | What it does | Primary API surface |
|---|---|---|
| VoiceOver | Reads UI aloud; gestures navigate | accessibilityLabel, accessibilityValue, accessibilityAdjustableAction, accessibilityElement(children:) |
| AssistiveTouch (watchOS 8+) | Hand-gesture and wrist-motion control; on-screen cursor; action menu | accessibilityRespondsToUserInteraction, contentShape, accessibilityAction |
| Double Tap (watchOS 11+) | Pinch-fingers gesture triggers primary action | handGestureShortcut(.primaryAction) |
Design for any combination — a user may have VoiceOver on and AssistiveTouch enabled. Neither should step on the other.
Dynamic Type — Three Rules
The watchOS 8 accessibility large text sizes put more pressure on Dynamic Type than any earlier release. First-run setup now asks every user to pick a text size; if they don't, watchOS picks the closest size to their iPhone's.
Rule 1 — Always use a text style, never a fixed font size
// Wrong — stays the same at any user-chosen size
Text(plant.name).font(.system(size: 18))
// Right — grows with the system text size
Text(plant.name).font(.title3)SwiftUI ships 11 text styles. Each one scales automatically across the full range, including the large accessibility sizes.
Rule 2 — Allow text to wrap; don't cap lineLimit at 1
// Wrong — truncates on larger sizes
Text(task.description).lineLimit(1)
// Right — wraps onto as many lines as needed
Text(task.description)
// Or: Text(...).lineLimit(3)A one-line limit on watch UI truncates by design. Accept wrapping by default; cap lineLimit only when the layout cannot tolerate reflow.
Rule 3 — Switch layout for very large sizes
Once text is wrapping three times and icons are crowding, give up on the horizontal layout and stack vertically:
struct PlantCardView: View {
@Environment(\.sizeCategory) private var sizeCategory
let plant: Plant
var body: some View {
if sizeCategory >= .extraExtraLarge {
VerticalPlantView(plant: plant)
} else {
HorizontalPlantView(plant: plant)
}
}
}Read @Environment(\.sizeCategory) and branch at the threshold where the horizontal layout breaks. Don't try to make a single layout work across the full range — the vertical fallback is the right tool for accessibility sizes.
Complications and notifications count
Two surfaces people forget:
- Complication text with abbreviations needs
accessibilityLabelwith the spoken form. "Wed Mar 9" →accessibilityLabel("Wednesday, March 9th"). - Complication images need a label too, or VoiceOver falls back to the image asset name. "Moon" becomes
accessibilityLabel("A real-time view of the moon. Third quarter."). - SF Symbols come with default labels (
"Drop, fill") that may not match your context — override withaccessibilityLabelwhen the context warrants. - Dynamic notifications need the same accessibility treatment as main-app views — labels, hierarchy, action labels.
VoiceOver on watchOS
Let NavigationLink combine children
A list row with four labels, four images, and two buttons forces VoiceOver to stop at every element. Grouping is usually what you want:
// If you're using accessibilityElement(children: .contain) to inspect the row, remove it:
NavigationLink(destination: PlantDetail(plant: plant)) {
PlantRowContents(plant: plant)
}
// NavigationLink will combine children into a single accessible element automatically.Context in the label, not just the value
VoiceOver without context is unusable. A task row reading "5 days. 7 days. Medium." tells the user nothing:
// Wrong — VoiceOver reads "5 days"
Text("5 days")
// Right — reads "Watering in 5 days"
struct PlantTaskLabel: View {
let task: PlantTask
var body: some View {
Label(task.displayText, systemImage: task.iconName)
.accessibilityLabel(task.voiceOverDescription)
}
}
extension PlantTask {
var voiceOverDescription: String {
switch self {
case .water(let days): "Watering in \(days) days"
case .fertilize(let days): "Fertilizing in \(days) days"
case .sunlight(let level): "Keep in \(level) sunlight"
}
}
}Buttons need explicit labels when using system symbols
A button that's just Image(systemName: "drop.fill") reads as "Drop fill, button". Override:
Button { log(.water) } label: {
Image(systemName: "drop.fill")
}
.accessibilityLabel("Log watering")Custom Adjustable Controls
A stepper built from two buttons and a label reads terribly: "Watering frequency. Remove, button. 8. Add, button." The user can't tell the three elements belong together.
Collapse them into one adjustable element:
struct FrequencyCounter: View {
let task: PlantTask
@Binding var days: Int
var body: some View {
HStack {
Button { days -= 1 } label: { Image(systemName: "minus.circle") }
Text("\(days)").font(.title)
Button { days += 1 } label: { Image(systemName: "plus.circle") }
}
.accessibilityElement() // Ignore children
.accessibilityLabel(task.frequencyLabel) // Read once on focus
.accessibilityValue("\(days) days") // Read on every change
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: days += 1
case .decrement: days -= 1
@unknown default: break
}
}
}
}The result: VoiceOver says "Watering frequency. 8 days. Adjustable." — and a swipe up/down adjusts it. One element, one label, one value, one action. That is the shape VoiceOver users expect for every custom control.
AssistiveTouch Support (watchOS 8+)
AssistiveTouch gives users with motor impairments full control via hand gestures (clench, double-clench, pinch, double-pinch) or wrist-motion cursor steering. Two concepts shape what the framework expects from your UI: focusable elements and cursor frames.
Focusable elements
AssistiveTouch cursor steps only through elements the system considers interactive. Default interactive elements:
| Interactive | Non-interactive |
|---|---|
Button, Toggle | Text (static) |
NavigationLink | Label (static) |
Elements with .onTapGesture | Elements with .accessibilityHidden(true) |
Elements with .accessibilityAction | Any element with .disabled(true) |
| Elements with an actionable a11y trait |
The trap is a VStack with an .onTapGesture on the whole stack — the stack is interactive, but the Text children inside aren't. AssistiveTouch focus skips the text:
// Problem — tap works, but AssistiveTouch never highlights the text
VStack {
Text("Double Americano").font(.headline)
Text("with oat milk").font(.caption)
}
.onTapGesture { showDrinkDetail() }
// Fix — opt the text into interactive treatment
VStack {
Text("Double Americano")
.font(.headline)
.accessibilityRespondsToUserInteraction(true)
Text("with oat milk").font(.caption)
}
.onTapGesture { showDrinkDetail() }accessibilityRespondsToUserInteraction(true) tells AssistiveTouch the element is part of the tap target.
Cursor frame
The AssistiveTouch cursor draws around the element's tappable area, not its visual bounds. A small glyph button has a small cursor that clips or hides the content. Expand the tap target with contentShape:
// Small visual element; tiny cursor frame; clips the icon
NavigationLink(destination: SettingsView()) {
Image(systemName: "ellipsis")
}
// Generous tappable area; readable cursor
NavigationLink(destination: SettingsView()) {
Image(systemName: "ellipsis")
.frame(width: 44, height: 44)
.contentShape(Circle())
}Minimum tappable region is 44×44pt regardless of visual size. contentShape(Circle()) makes the AssistiveTouch cursor trace a circle that matches the visual icon shape.
Action menu
AssistiveTouch's action menu (double-clench opens it) surfaces custom actions ahead of system actions for the focused element. Any accessibilityAction you already added for VoiceOver appears here automatically:
TaskRow(task: task)
.accessibilityAction(named: "Complete") { task.complete() }
.accessibilityAction(named: "Reschedule") { showReschedule() }Adjustable elements surface increment/decrement. Custom actions without an explicit image use the first letter of the action name as the menu icon — provide a Label to override:
.accessibilityAction {
Label("Complete", systemImage: "checkmark.circle.fill")
} action: {
task.complete()
}Double Tap (watchOS 11+)
Double Tap is a pinch-fingers gesture bound globally to the primary action of the frontmost view. For most UI, the primary action is the obvious "default" button: "Start" in a workout app, "Reply" on an incoming message.
Opt a button into Double Tap
Button("Start Workout") {
session.start()
}
.handGestureShortcut(.primaryAction)Rules
- Only one primary action per screen — Double Tap becomes ambiguous otherwise
- Double Tap is an accelerator, not a replacement — every primary-action button must remain tappable
- The user can disable Double Tap globally; don't make it the only path to any action
- Controls (see
axiom-watchos/skills/controls-and-live-activities.md) bind to Double Tap automatically when the control widget is frontmost
Testing Checklist
| Test | How |
|---|---|
| VoiceOver reads every row with context | Settings → Accessibility → VoiceOver; swipe through a list and listen |
Dynamic Type reflows at accessibility5 | Settings → Accessibility → Display & Text Size → Larger Text → All the way up |
| Custom adjustable controls speak label + value separately | Focus and listen; swipe up/down to increment |
| AssistiveTouch highlights every tappable element | Settings → Accessibility → AssistiveTouch → on; pinch-step through the screen |
| AssistiveTouch cursor doesn't clip icons | Visual check with cursor visible |
| Double Tap fires the intended action | Pinch with the same hand as the watch |
| Complication text has spoken-form label | VoiceOver focus on the watch face with your complication |
| Notification long-look is navigable | VoiceOver on a delivered notification |
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Fixed font size on labels | Text stays small at accessibility sizes | Use a text style (.title3, .body, .caption); never Font.system(size:) for user-facing text |
lineLimit(1) on text that can vary in length | Truncation at large Dynamic Type; no way for users to read the content | Remove or raise lineLimit; switch layout vertically at accessibility sizes |
accessibilityElement(children: .contain) on a NavigationLink row | VoiceOver navigates past every inner element instead of grouping the row | Remove the modifier; NavigationLink combines children automatically |
System symbol button without accessibilityLabel | VoiceOver reads "Drop fill, button" | Add .accessibilityLabel("Log watering") |
Custom stepper built from buttons without accessibilityAdjustableAction | VoiceOver reads two separate button labels and the value; no swipe-to-adjust | accessibilityElement() + accessibilityLabel + accessibilityValue + accessibilityAdjustableAction |
Static Text inside a tappable VStack isn't AssistiveTouch-focusable | Cursor skips rows; users can't reach the tap target | Add .accessibilityRespondsToUserInteraction(true) on the text elements |
Small glyph button without contentShape | AssistiveTouch cursor clips the icon; tap area smaller than 44×44 | .frame(width: 44, height: 44).contentShape(...) |
| Custom accessibility actions with no image | Menu shows one-letter placeholders | Attach a Label(_:systemImage:) to each accessibilityAction |
| Binding Double Tap to multiple primary actions | Double Tap becomes unpredictable on the screen | Use exactly one .handGestureShortcut(.primaryAction) per surface |
Using handGestureShortcut(.primaryAction) as the only path | Users who disable Double Tap can't reach the action | Keep the button tappable; Double Tap is an accelerator only |
Abbreviated complication text without an unabridged accessibilityLabel | VoiceOver reads "Wed Mar 9" instead of "Wednesday, March 9th" | Provide accessibilityLabel for every abbreviated string in complications and notifications |
Resources
WWDC: 2021-10223, 2021-10308, 2024-10205
Docs: /watchos-apps/create-accessible-experiences-for-watchos, /swiftui/environmentvalues/sizecategory, /swiftui/view/accessibilityelement(children:), /swiftui/view/accessibilityrespondstouserinteraction(_:), /swiftui/view/accessibilityadjustableaction(_:), /swiftui/view/accessibilityaction(_:_:), /swiftui/view/contentshape(_:), /swiftui/view/handgestureshortcut(_:isenabled:)
Skills: axiom-accessibility (accessibility-diag, ux-flow-audit), axiom-watchos (design-for-watchos, smart-stack-and-complications, controls-and-live-activities)
Related skills
How it compares
Pick axiom-accessibility for native Apple and Axiom-specific audits; pick web accessibility checker skills when the surface is HTML/CSS rather than UIKit or SwiftUI controls.
FAQ
What accessibility areas does axiom-accessibility cover?
axiom-accessibility audits VoiceOver labels and traits, Dynamic Type scaling, color contrast, focus order, and WCAG-aligned checks on Apple apps so assistive-technology users can navigate native UI before App Store review.
When should developers invoke axiom-accessibility?
axiom-accessibility fits pre-release Apple builds when VoiceOver navigation fails, text fails to scale under Dynamic Type, contrast is insufficient, or an Axiom app needs remediation to pass accessibility review.