Concrete accessibility implementations for SwiftUI apps on macOS and iOS. Covers: adaptive glass effects that fall back to solid backgrounds when Reduce Transparency is enabled, motion-safe animations that respect Reduce Motion, VoiceOver support (accessible labels, custom actions, rotor entries, state change announcements for both macOS and iOS), Dynamic Type with @ScaledMetric and adaptive grid layouts, keyboard navigation with FocusState and onKeyPress, Commands menus with keyboard shortcuts, high contrast mode detection, color-blind safe status indicators using accessibilityDifferentiateWithoutColor, and a complete testing checklist. Use when building any user-facing SwiftUI app to ensure it meets accessibility standards. Especially important when using Liquid Glass (which needs Reduce Transparency fallbacks) or custom animations (which need Reduce Motion handling).
Concrete implementations for VoiceOver, Dynamic Type, Reduce Motion, keyboard navigation, and color contrast. These patterns apply to both macOS and iOS SwiftUI apps.
accessibilityReduceMotionaccessibilityReduceTransparency fallbackGlass 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))
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))
}
}
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
}
}
}
}
}
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)
}
}
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() }
}
}
}
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)
}
}
}
}
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
}
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()
}
}
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)
}
}
}
}
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
}
}
}
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])
}
}
}
struct ContrastAwareText: View {
let text: String
@Environment(\.colorSchemeContrast) var contrast
var body: some View {
Text(text)
.foregroundStyle(contrast == .increased ? .primary : .secondary)
}
}
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"
}
}
}
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
}
}
.accessibilityLabel.accessibility3 sizewithAnimation calls gated on accessibilityReduceMotion