| name | ios-accessibility |
| description | iOS accessibility applied — Dynamic Type (built-in styles + AX1–AX5 sizes, @ScaledMetric, custom-font relativeTo, AX5 testing), VoiceOver (labels/values/hints/traits, element grouping, reading order, rotor, decorative hiding), color & contrast (WCAG AA 4.5:1/3:1, Increase Contrast, Differentiate Without Color, never color-only), 44pt touch targets, motion/transparency settings, alternative input (Voice Control/Switch Control/Full Keyboard Access), and auditing (Accessibility Inspector + performAccessibilityAudit). Use when building, reviewing, or auditing the accessibility of any native iOS/SwiftUI screen. |
| tags | ["ios","accessibility","a11y","voiceover","dynamic-type","swiftui","hig"] |
iOS accessibility — Dynamic Type, VoiceOver, contrast, and audits
Source: Apple HIG Accessibility (developer.apple.com/design/human-interface-guidelines/accessibility), the SwiftUI accessibility modifiers reference + @Environment accessibility keys, and the performAccessibilityAudit / Accessibility Inspector tooling (WWDC23 "Perform accessibility audits", carried into Xcode 26/27). Plus WWDC 2026 / iOS 27 Liquid Glass refinement coverage (MacRumors, TechTimes, Neowin — June 2026) for the new transparency slider and material legibility changes (§6). iOS 26 = shipping baseline; iOS 27 (WWDC 2026) = current cycle — most of this skill is version-stable; the iOS 27 deltas are the Liquid Glass transparency slider and material-legibility tuning, flagged inline. Cross-checked across multiple passes (June 2026). Apple's stance: accessibility is a foundational design decision, not a retrofit — design for it from the first layout.
1. The system settings you must respond to
Apple ships these as user toggles; your app inherits some for free and must honor the rest. Each maps to a SwiftUI @Environment key (read once, branch your view):
| Setting | @Environment key | What you do |
|---|
| Larger Text (Dynamic Type) | \.dynamicTypeSize | Use built-in text styles; reflow at AX sizes (§2) |
| Bold Text | (inherited automatically) | Don't fight it; test layouts don't clip |
| Reduce Motion | \.accessibilityReduceMotion | Drop large transitions; cross-fade instead (§6) |
| Reduce Transparency | \.accessibilityReduceTransparency | Swap materials/blur for opaque fills (still binary; see §6 for the iOS 27 slider) |
| Increase Contrast | \.colorSchemeContrast (.increased) | Use higher-contrast color pair |
| Differentiate Without Color | \.accessibilityDifferentiateWithoutColor | Add shape/text/icon, not just hue (§4) |
| VoiceOver running | \.accessibilityVoiceOverEnabled | Rarely branch on this — fix labels instead |
Don't gate core meaning on any single setting. These refine an already-accessible layout; they are not the layout.
2. Dynamic Type
Use the built-in text styles (.largeTitle….caption2) — they scale with the user's setting and reach the five accessibility sizes. Sizes: xSmall, small, medium, large (default) , xLarge, xxLarge, xxxLarge, then AX1–AX5 (accessibilityMedium … accessibilityExtraExtraExtraLarge). AX = accessibility.
| Text style | Default (Large) | At AX5 (largest) | SwiftUI |
|---|
| Large Title | 34 pt | ~53 pt | .largeTitle |
| Body | 17 pt | ~53 pt | .body |
| Headline | 17 pt (Semibold) | ~53 pt | .headline |
| Footnote | 13 pt | ~38 pt | .footnote |
| Caption 1 | 12 pt | ~36 pt | .caption |
Verify-at-source flag: Default-size values are the long-stable iOS scale (confirmed). Body and Large Title both reach ~53 pt at AX5 (accessibilityExtraExtraExtraLarge) — confirmed across two sources. The full AX1–AX5 value for every style is not uniformly published by Apple as a single table; styles scale at different rates. Don't hard-code intermediate AX values — read them at runtime, or confirm against Apple Design Resources / on-device before locking a spec.
Custom fonts must scale. A bare Font.custom(_:size:) ignores Dynamic Type — always pass relativeTo::
Text("1,840").font(.custom("Figtree-Medium", size: 36, relativeTo: .largeTitle))
Scale non-text metrics (icon, padding, custom frame) with @ScaledMetric so they grow with the font:
@ScaledMetric(relativeTo: .body) private var iconSize = 24
Image(systemName: "flame.fill").frame(width: iconSize, height: iconSize)
Reflow, don't truncate. At AX sizes a horizontal row of metrics will overflow. Cap where layout must stay fixed, then branch to a vertical layout for big sizes:
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
let layout = typeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading))
: AnyLayout(HStackLayout())
layout { macroTiles }
}
dynamicTypeSize.isAccessibilitySize is the canonical "are we at AX1+?" check. Test at AX5 (Settings ▸ Accessibility ▸ Display & Text Size ▸ Larger Text, or the Xcode Dynamic Type preview/Environment Overrides) — most layout breakage only appears there.
Resizable / foldable layouts raise the reflow bar. With resizable iPad windows and foldable form factors, "AX5 in a narrow column" is now a real combination — the same screen can hit the largest text size and the smallest width at once. Don't pin to device width; drive layout off size classes / available width and isAccessibilitySize together, and verify the worst case (narrowest width × AX5) reflows instead of clipping.
3. VoiceOver
Every meaningful element needs a label; interactive/changing ones add value, traits, and (optionally) a hint.
HStack { Text("Protein"); Spacer(); Text("92 / 140 g") }
.accessibilityElement(children: .combine)
.accessibilityLabel("Protein")
.accessibilityValue("92 of 140 grams")
.accessibilityHint("Opens protein detail")
Button(action: log) { Image(systemName: "plus") }
.accessibilityLabel("Add food")
ProgressView(value: 0.66)
.accessibilityValue(Text("66 percent of goal"))
Rules:
- Label = what it is (short, no control type — VoiceOver adds "button"). Value = current state ("92 of 140 grams", "On"). Hint = what happens (a sentence; users can disable hints).
- Traits describe role/state:
.accessibilityAddTraits(.isButton / .isHeader / .isSelected / .updatesFrequently); remove with .accessibilityRemoveTraits. Mark section titles .isHeader so the heading rotor works.
- Grouping:
.accessibilityElement(children: .combine) merges a label+value pair into one swipe; .ignore makes the container itself the element; .contain keeps children individually navigable.
- Decorative imagery:
.accessibilityHidden(true) on pure-decoration views (background blobs, redundant icons) so VoiceOver skips them.
- Reading order: by default it follows layout top-leading→bottom-trailing. Override only when visual order ≠ logical order, using
.accessibilitySortPriority(_:) (higher reads first within a container).
- Rotor: headings/links/form-controls rotors come free from correct traits; add a custom
.accessibilityRotor to jump between repeated items (e.g. each logged meal).
4. Color & contrast
Meet WCAG 2.2 AA (Apple's stated floor): 4.5:1 for body text, 3:1 for large text (≥18 pt, or ≥14 pt bold) and for UI components / meaningful graphics. Increase Contrast raises the bar toward 7:1.
- Never encode meaning with color alone. A red "over budget" number, a green "on track" macro, a colored dot — all fail for color-blind users. Add a glyph, label, shape, or text. When
accessibilityDifferentiateWithoutColor is on, add the redundant cue:
@Environment(\.accessibilityDifferentiateWithoutColor) private var noColor
Label("Over", systemImage: noColor ? "exclamationmark.triangle.fill" : "circle.fill")
.foregroundStyle(.red)
- Increase Contrast: branch on
\.colorSchemeContrast == .increased to pick a darker/more saturated pair. Prefer semantic system colors (.primary, Color(.label), .secondary) — they already shift for contrast and dark mode (see ios-color-and-materials).
- Don't measure contrast against a gradient or photo — test against the actual rendered background (e.g. dark text on a yellow tile, white text on a photo overlay).
5. Touch targets & hit regions
Minimum 44×44 pt for every tappable control (Apple's long-standing floor). A 24 pt icon needs padding/frame to reach 44:
Button(action: edit) { Image(systemName: "pencil") }
.frame(minWidth: 44, minHeight: 44)
.contentShape(Rectangle())
Keep ~8 pt between adjacent targets. The performAccessibilityAudit hitRegion check flags anything under 44².
6. Motion, transparency & visual comfort
Reduce Motion — drop springy/zoom/parallax transitions; a cross-fade or instant change is fine. Guard withAnimation:
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func confettiBurst() {
if reduceMotion { showResult = true }
else { withAnimation(.spring) { showResult = true } }
}
func maybeAnimate<R>(_ body: () -> R) -> R {
reduceMotion ? body() : withAnimation(.spring, body)
}
Reduce Transparency — swap blur/Material for an opaque fill:
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
.background(reduceTransparency ? AnyShapeStyle(Color(.systemBackground)) : AnyShapeStyle(.regularMaterial))
iOS 27 / WWDC 2026 — Liquid Glass transparency slider. iOS 27 adds a user-facing transparency slider (in Settings, and surfaced on the setup screen) that dials Liquid Glass from "ultra clear" to fully tinted/opaque. This is a graduated control, distinct from the existing binary accessibilityReduceTransparency toggle — and the iOS 27 default glass is already less transparent than iOS 26. Apple also tuned the material itself (better diffusion of busy content, darkened edges, brighter specular highlights) and a uniform toolbar appears as content scrolls under floating bars to keep text legible. What this means for you:
- Don't assume glass is legible. The new control exists because iOS 26 translucency dropped contrast below readable thresholds over busy backgrounds (NN/g + Apple-forum findings). Glass over a photo/gradient still needs the §4 contrast check on the rendered result — the slider is a user escape hatch, not a guarantee your overlay passes.
- Don't hard-code a glass intensity assuming a fixed translucency — the user can move the slider anywhere, and your text must stay readable across the range.
accessibilityReduceTransparency is still the binary you branch on in code. Liquid Glass auto-adapts to Reduce Transparency and Increase Contrast, but for custom materials keep the opaque-fallback above; honor the toggle rather than reading the slider value. Verify-at-source flag: Apple has not published whether the slider exposes a new @Environment value or whether it simply feeds the existing transparency/contrast settings — treat it as the latter (no new API to read) until confirmed against Apple docs/on-device.
Also: respect Bold Text automatically; avoid flashing/strobing content; don't auto-play motion in the background.
7. Alternative input (motor / no-touch)
You usually don't write per-input code — you make controls real and labeled, and these all work:
- Voice Control — speaks control names; the spoken name comes from your
accessibilityLabel. Unlabeled icon buttons become "tap [number]" only. Label everything.
- Switch Control — steps focus through accessibility elements; depends on correct grouping/order (§3) and real focusable controls (not tap-gesture-on-a-Text).
- Full Keyboard Access — every interactive element must be reachable by Tab and show a visible focus ring; use standard controls and
.focusable() / @FocusState for custom ones.
- AssistiveTouch / Switch users benefit from larger targets (§5) and forgiving spacing.
Net rule: prefer standard SwiftUI controls (
Button, Toggle, Slider) over a Text + .onTapGesture — only the former is automatically accessible to all four input systems.
8. Auditing
performAccessibilityAudit() in a UI test runs the same checks as Accessibility Inspector and fails the test on any issue — wire it into CI:
func testTodayScreenAccessibility() throws {
let app = XCUIApplication(); app.launch()
try app.performAccessibilityAudit()
}
Scope with try app.performAccessibilityAudit(for: [.contrast, .dynamicType, .hitRegion]), and pass an issue handler to suppress known false positives.
- Accessibility Inspector (Xcode ▸ Open Developer Tool) — live inspect labels/traits/values on the Simulator, run a one-shot audit, and use the VoiceOver simulator to hear the screen.
- On-device manual checklist: turn on VoiceOver and swipe the whole screen (order + labels make sense?) · set Larger Text to AX5 (nothing clipped/overlapping?) · turn on Reduce Motion (celebration calm?) · Reduce Transparency · Bold Text · Increase Contrast · Differentiate Without Color · drive one full flow with Full Keyboard Access.
9. Mistakes that read as cheap / amateur
Icon-only buttons with no accessibilityLabel (VoiceOver/Voice Control say "button"/"tap 7") · meaning carried by color alone (red=bad, green=good) · hard-coded .font(.system(size:)) that ignores Dynamic Type · rows that truncate at AX5 instead of reflowing · putting state in the label so VoiceOver doesn't re-announce it on change (use value) · tappable Text().onTapGesture instead of Button (invisible to Switch/Full Keyboard Access) · sub-44 pt targets · decorative images not hidden (VoiceOver reads "image, image, image") · animations that ignore Reduce Motion · never running an audit.
10. Greg application
Greg = native SwiftUI iOS 26 nutrition app — cobalt #2C50F0 primary, Figtree (Medium/600), color-blocked macro tiles (pink/yellow/mint), a black photo-detail log screen, charts on Progress.
- Macro tiles must not rely on color alone. Pink/yellow/mint distinguish protein/carbs/fat visually — but the tile already shows the macro name + grams, so keep that text visible and never collapse a tile to just its color. Each tile:
.accessibilityElement(children: .combine) + .accessibilityLabel("Protein") + .accessibilityValue("92 of 140 grams") (not "92/140" — spell it).
- Verify contrast on the literal surfaces: cobalt #2C50F0 on white as button/text (needs ≥4.5:1 for text, ≥3:1 as a large control — confirm with a checker, it's borderline as small text) and dark text on the yellow tile (yellow is the dangerous one — must clear 4.5:1; darken the text or the tile if not). White text on the black photo-log screen is fine.
- Dynamic Type on the calorie hero: the big remaining-calories number uses
.font(.largeTitle/.title, design:…).monospacedDigit() with Dynamic Type on; at AX5 it grows to ~53 pt — let the layout stack (macro tiles drop below the hero via ViewThatFits/isAccessibilitySize) rather than shrinking or clipping the number.
- VoiceOver reading order on Today: hero calories first → macro tiles (each a single combined element) → check-in/log actions. Mark section titles
.isHeader. Hide decorative ring fills/background art with .accessibilityHidden(true).
- The celebration must respect Reduce Motion: the goal-hit burst/confetti is guarded by
\.accessibilityReduceMotion — when on, show the success state with a cross-fade or instantly, no springs or particles.
- Charts on Progress: give each series an
.accessibilityLabel/.accessibilityValue (or Swift Charts' audio-graph support) — a bare chart is invisible to VoiceOver. See ios-charts-and-data-visualization.
Pairs with: ios-typography (Dynamic Type styles, scaling), ios-color-and-materials (semantic colors, contrast, Reduce Transparency), accessibility-and-inclusive-design (cross-platform WCAG 2.2 foundation), ios-charts-and-data-visualization (accessible charts).