accessibility
Accessibility Patterns
Concrete implementations for VoiceOver, Dynamic Type, Reduce Motion, keyboard navigation, and color contrast. These patterns apply to both macOS and iOS SwiftUI apps.
Critical Rules
- ❌ Never rely on color alone to convey information — always pair with icons or text
- ❌ Never use glass effects without a solid fallback for Reduce Transparency
- ❌ Never ship animations without Reduce Motion handling
- ❌ Never skip VoiceOver labels on interactive elements
- ✅ Every interactive element must have an accessibility label
- ✅ Every state change must be announced to VoiceOver
- ✅ Every animation must respect
accessibilityReduceMotion - ✅ Every glass effect must have a
accessibilityReduceTransparencyfallback
Adaptive Glass Effect
Glass effects become unreadable when Reduce Transparency is enabled. Always provide a solid fallback.
struct AdaptiveGlassModifier: ViewModifier {
@Environment(\.accessibilityReduceTransparency) var reduceTransparency
let style: GlassEffectStyle
let shape: AnyShape
func body(content: Content) -> some View {
if reduceTransparency {
content
.background(Color(.systemBackground))
.clipShape(shape)
} else {
content
.glassEffect(style, in: shape)
}
}
}
extension View {
func adaptiveGlass(
_ style: GlassEffectStyle = .regular,
in shape: some Shape = .rect
) -> some View {
modifier(AdaptiveGlassModifier(style: style, shape: AnyShape(shape)))
}
}
// Usage
Text("Accessible Glass")
.padding()
.adaptiveGlass(.regular, in: .rect(cornerRadius: 12))
Motion-Safe Animations
ViewModifier Approach
struct MotionSafeAnimation<V: Equatable>: ViewModifier {
@Environment(\.accessibilityReduceMotion) var reduceMotion
let animation: Animation
let value: V
func body(content: Content) -> some View {
content.animation(reduceMotion ? .none : animation, value: value)
}
}
extension View {
func motionSafeAnimation<V: Equatable>(
_ animation: Animation = .default,
value: V
) -> some View {
modifier(MotionSafeAnimation(animation: animation, value: value))
}
}
Conditional withAnimation
struct MorphingCard: View {
@State private var isExpanded = false
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
VStack { /* content */ }
.onChange(of: isExpanded) { _, newValue in
if reduceMotion {
// Instant change, no animation
} else {
withAnimation(.spring(response: 0.3)) {
// Animated change
}
}
}
}
}
VoiceOver Support
Accessible Labels
struct PromptRow: View {
let prompt: Prompt
var body: some View {
HStack {
Image(systemName: prompt.icon)
VStack(alignment: .leading) {
Text(prompt.title)
Text(prompt.preview)
.font(.caption)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(prompt.title), \(prompt.category?.name ?? "uncategorized")")
.accessibilityHint("Double tap to copy prompt")
.accessibilityAddTraits(.isButton)
}
}
Custom Actions
Expose multiple actions without requiring complex gesture discovery.
struct PromptCard: View {
let prompt: Prompt
var onCopy: () -> Void
var onEdit: () -> Void
var onDelete: () -> Void
var body: some View {
VStack { /* card content */ }
.accessibilityElement(children: .combine)
.accessibilityLabel(prompt.title)
.accessibilityActions {
Button("Copy to clipboard") { onCopy() }
Button("Edit") { onEdit() }
Button("Delete", role: .destructive) { onDelete() }
}
}
}
Rotor Support
Let VoiceOver users navigate by category or filter.
struct PromptListView: View {
let prompts: [Prompt]
@State private var favorites: Set<UUID> = []
var body: some View {
List(prompts) { prompt in
PromptRow(prompt: prompt)
}
.accessibilityRotor("Favorites") {
ForEach(prompts.filter { favorites.contains($0.id) }) { prompt in
AccessibilityRotorEntry(prompt.title, id: prompt.id)
}
}
.accessibilityRotor("Categories") {
ForEach(Category.all) { category in
AccessibilityRotorEntry(category.name, id: category.id)
}
}
}
}
Announce State Changes
Always announce clipboard operations and other invisible state changes.
func copyPrompt(_ prompt: Prompt) {
ClipboardService.shared.copy(prompt.content)
#if os(iOS)
UIAccessibility.post(
notification: .announcement,
argument: "Copied \(prompt.title) to clipboard"
)
#else
NSAccessibility.post(
element: NSApp.mainWindow as Any,
notification: .announcementRequested,
userInfo: [.announcement: "Copied \(prompt.title) to clipboard"]
)
#endif
}
Dynamic Type
Scaled Metrics
struct PromptDetailView: View {
let prompt: Prompt
@ScaledMetric(relativeTo: .body) var iconSize = 24
@ScaledMetric(relativeTo: .title) var headerPadding = 16
var body: some View {
VStack(alignment: .leading, spacing: headerPadding) {
HStack {
Image(systemName: prompt.icon)
.font(.system(size: iconSize))
Text(prompt.title)
.font(.title)
}
Text(prompt.content)
.font(.body)
}
.padding()
}
}
Adaptive Grid Layouts
Reduce columns as text size increases to prevent truncation.
struct AdaptivePromptGrid: View {
let prompts: [Prompt]
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var columns: [GridItem] {
if dynamicTypeSize >= .accessibility1 {
return [GridItem(.flexible())] // 1 column
} else if dynamicTypeSize >= .xxxLarge {
return [GridItem(.flexible()), GridItem(.flexible())] // 2 columns
} else {
return Array(repeating: GridItem(.flexible()), count: 3) // 3 columns
}
}
var body: some View {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(prompts) { prompt in
PromptCard(prompt: prompt)
}
}
}
}
Keyboard Navigation (macOS)
Focus Management with FocusState
struct QuickAccessView: View {
@FocusState private var focusedPrompt: UUID?
let prompts: [Prompt]
var body: some View {
VStack {
ForEach(prompts) { prompt in
PromptRow(prompt: prompt)
.focused($focusedPrompt, equals: prompt.id)
}
}
.onKeyPress(.upArrow) {
moveFocus(direction: -1)
return .handled
}
.onKeyPress(.downArrow) {
moveFocus(direction: 1)
return .handled
}
.onKeyPress(.return) {
if let id = focusedPrompt { selectPrompt(id: id) }
return .handled
}
}
func moveFocus(direction: Int) {
guard let current = focusedPrompt,
let index = prompts.firstIndex(where: { $0.id == current }) else {
focusedPrompt = prompts.first?.id
return
}
let newIndex = index + direction
if prompts.indices.contains(newIndex) {
focusedPrompt = prompts[newIndex].id
}
}
}
Commands Menu with Keyboard Shortcuts
struct PromptManagerCommands: Commands {
var body: some Commands {
CommandGroup(after: .newItem) {
Button("New Prompt") { }
.keyboardShortcut("n", modifiers: .command)
Button("New Category") { }
.keyboardShortcut("n", modifiers: [.command, .shift])
}
CommandMenu("Prompts") {
Button("Copy Selected") { }
.keyboardShortcut("c", modifiers: [.command, .shift])
Button("Quick Access") { }
.keyboardShortcut(" ", modifiers: [.command, .shift])
}
}
}
Color Contrast & Color Blindness
High Contrast Mode
struct ContrastAwareText: View {
let text: String
@Environment(\.colorSchemeContrast) var contrast
var body: some View {
Text(text)
.foregroundStyle(contrast == .increased ? .primary : .secondary)
}
}
Color-Blind Safe Status Indicators
Always pair color with icon and optional text label.
struct StatusIndicator: View {
let status: Status
@Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor
enum Status { case success, warning, error }
var body: some View {
HStack {
Image(systemName: iconName)
.foregroundStyle(color)
if differentiateWithoutColor {
Text(statusText)
.font(.caption)
}
}
}
var iconName: String {
switch status {
case .success: "checkmark.circle.fill"
case .warning: "exclamationmark.triangle.fill"
case .error: "xmark.circle.fill"
}
}
var color: Color {
switch status {
case .success: .green
case .warning: .yellow
case .error: .red
}
}
var statusText: String {
switch status {
case .success: "Success"
case .warning: "Warning"
case .error: "Error"
}
}
}
System Preferences Detection
Observable object that monitors all accessibility settings at runtime.
class AccessibilitySettings: ObservableObject {
@Published var reduceTransparency: Bool
@Published var reduceMotion: Bool
@Published var differentiateWithoutColor: Bool
@Published var isVoiceOverRunning: Bool
init() {
#if os(macOS)
reduceTransparency = NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency
reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
differentiateWithoutColor = NSWorkspace.shared.accessibilityDisplayShouldDifferentiateWithoutColor
isVoiceOverRunning = NSWorkspace.shared.isVoiceOverEnabled
#else
reduceTransparency = UIAccessibility.isReduceTransparencyEnabled
reduceMotion = UIAccessibility.isReduceMotionEnabled
differentiateWithoutColor = UIAccessibility.shouldDifferentiateWithoutColor
isVoiceOverRunning = UIAccessibility.isVoiceOverRunning
#endif
observeChanges()
}
private func observeChanges() {
#if os(iOS)
NotificationCenter.default.addObserver(
forName: UIAccessibility.reduceTransparencyStatusDidChangeNotification,
object: nil, queue: .main
) { [weak self] _ in
self?.reduceTransparency = UIAccessibility.isReduceTransparencyEnabled
}
NotificationCenter.default.addObserver(
forName: UIAccessibility.reduceMotionStatusDidChangeNotification,
object: nil, queue: .main
) { [weak self] _ in
self?.reduceMotion = UIAccessibility.isReduceMotionEnabled
}
#endif
}
}
Testing Checklist
VoiceOver
- All interactive elements have
.accessibilityLabel - Custom actions available where context menus exist
- Logical reading order (test by swiping through elements)
- Announcements for state changes (copy, delete, save)
- Rotors configured for key navigation paths
Dynamic Type
- All text scales via system fonts (no hardcoded sizes)
- Layout adapts at accessibility sizes (grid → single column)
- No truncation at
.accessibility3size - Touch targets remain ≥ 44pt at all sizes
Reduce Motion
- All
withAnimationcalls gated onaccessibilityReduceMotion - Glass morphing effects simplified or removed
- No essential information conveyed only through animation
- Transitions fall back to opacity-only
Reduce Transparency
- Glass effects have solid background fallback
- Sufficient contrast maintained without blur
- Readability preserved on all backgrounds
Keyboard (macOS)
- All features accessible via keyboard alone
- Logical tab/focus order
- Visible focus indicators
- Standard shortcuts work (⌘N, ⌘F, ⌘,)
- Custom shortcuts registered in Commands menu
More from makgunay/claude-swift-skills
macos-app-structure
macOS application architecture patterns covering App protocol (@main), Scene types (WindowGroup, Window, Settings, MenuBarExtra), multi-window management, NSApplicationDelegateAdaptor for AppKit lifecycle hooks, Info.plist configuration (LSUIElement for menu bar apps, NSAccessibilityUsageDescription), entitlements for sandbox/hardened runtime, and project structure conventions. Use when scaffolding a new macOS app, configuring scenes and windows, setting up menu bar apps, or resolving macOS-specific lifecycle issues. Corrects the common LLM mistake of generating iOS-only app structures.
31macos-permissions
macOS permission handling for Accessibility (AXIsProcessTrusted), Screen Recording, Full Disk Access, input monitoring, camera, microphone, location, and contacts. Covers TCC (Transparency Consent and Control) database, graceful degradation when permissions are denied, permission prompting patterns, opening System Settings to the correct pane, detecting permission changes, and the privacy manifest (PrivacyInfo.xcprivacy) requirement. Use when implementing features that require system permissions, building permission onboarding flows, or handling denied permissions gracefully.
17appkit-bridge
Bridging AppKit components into SwiftUI macOS apps. Covers NSViewRepresentable and NSViewControllerRepresentable protocols for hosting AppKit views in SwiftUI, NSHostingView/NSHostingController for hosting SwiftUI in AppKit, NSPanel for floating windows, NSWindow configuration (styleMask, level, collectionBehavior), responder chain integration, NSEvent monitoring (global and local), NSAnimationContext for AppKit animations, NSPopover, NSStatusItem for menu bar, and NSGlassEffectView for AppKit Liquid Glass. Use when SwiftUI lacks a native equivalent, building floating panels, custom window chrome, or integrating legacy AppKit components.
15swiftui-core
Core SwiftUI patterns for macOS and iOS development including navigation (NavigationSplitView, NavigationStack), state management (@State, @Binding, @Environment, @Bindable with @Observable), the new customizable toolbar system (toolbar IDs, ToolbarSpacer, DefaultToolbarItem, searchToolbarBehavior, matchedTransitionSource, sharedBackgroundVisibility), styled text editing (TextEditor with AttributedString, AttributedTextSelection, transformAttributes, textFormattingDefinition), and layout patterns. Use when building any SwiftUI view, implementing navigation, managing state, creating toolbars, or building rich text editors. Corrects common LLM errors like using deprecated NavigationView, wrong state wrappers, and outdated toolbar APIs.
14global-hotkeys
System-wide keyboard shortcut registration on macOS using NSEvent monitoring (simple, app-level) and Carbon EventHotKey API (reliable, system-wide). Covers NSEvent.addGlobalMonitorForEvents and addLocalMonitorForEvents, CGEvent tap for keystroke simulation, Carbon RegisterEventHotKey for system-wide hotkeys, modifier flag handling (.deviceIndependentFlagsMask), common key code mappings, debouncing, Accessibility permission requirements (AXIsProcessTrusted), and SwiftUI .onKeyPress for in-app shortcuts. Use when implementing global keyboard shortcuts, hotkey-triggered panels, or system-wide key event monitoring.
11swiftui-webkit
Native SwiftUI WebKit integration with the new WebView struct and WebPage observable class. Covers WebView creation from URLs, WebPage for navigation control and state management, JavaScript execution (callJavaScript with arguments and content worlds), custom URL scheme handlers, navigation management (load, reload, back/forward), navigation decisions, text search (findNavigator), content capture (snapshots, PDF generation, web archives), and configuration (data stores, user agents, JS permissions). Use when embedding web content in SwiftUI apps instead of the old WKWebView + UIViewRepresentable/NSViewRepresentable bridge pattern. This is a brand new API — do NOT use the old WKWebView wrapping approach.
10