How to Build a Color Blind Friendly UI in SwiftUI
Read @Environment(\.accessibilityDifferentiateWithoutColor) and swap pure-color cues for SF Symbol + color pairs so meaning survives for every type of color vision deficiency — no third-party library needed.
struct StatusBadge: View {
let isOnline: Bool
@Environment(\.accessibilityDifferentiateWithoutColor) var differentiates
var body: some View {
Label {
Text(isOnline ? "Online" : "Offline")
} icon: {
Image(systemName: differentiates
? (isOnline ? "checkmark.circle.fill" : "xmark.circle.fill")
: "circle.fill")
.foregroundStyle(isOnline ? .green : .red)
}
.accessibilityLabel(isOnline ? "Online" : "Offline")
}
}
Full implementation
The example below builds a task list where each row carries a priority badge. Without accessibility settings, badges are color-coded green / yellow / red. When the user turns on Differentiate Without Color in Settings → Accessibility → Display & Text Size, the badges switch to distinct SF Symbols so color is no longer the sole channel of information. A shared Priority enum drives both rendering paths, keeping the logic centralised and testable.
import SwiftUI
// MARK: - Model
enum Priority: String, CaseIterable, Identifiable {
case low, medium, high
var id: String { rawValue }
var label: String { rawValue.capitalized }
/// Color used in both rendering paths.
var color: Color {
switch self {
case .low: .green
case .medium: .yellow
case .high: .red
}
}
/// Symbol shown only when differentiateWithoutColor is true.
var symbol: String {
switch self {
case .low: "arrow.down.circle.fill"
case .medium: "minus.circle.fill"
case .high: "exclamationmark.circle.fill"
}
}
}
struct Task: Identifiable {
let id = UUID()
var title: String
var priority: Priority
}
// MARK: - Priority badge
struct PriorityBadge: View {
let priority: Priority
@Environment(\.accessibilityDifferentiateWithoutColor) var differentiates
var body: some View {
HStack(spacing: 4) {
Image(systemName: differentiates ? priority.symbol : "circle.fill")
.foregroundStyle(priority.color)
.imageScale(.small)
if differentiates {
Text(priority.label)
.font(.caption2.weight(.semibold))
.foregroundStyle(priority.color)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(priority.color.opacity(0.12), in: Capsule())
// Provide a single accessible description for the whole badge.
.accessibilityElement(children: .ignore)
.accessibilityLabel("Priority: \(priority.label)")
}
}
// MARK: - Task row
struct TaskRow: View {
let task: Task
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(task.title)
.font(.body)
PriorityBadge(priority: task.priority)
}
Spacer()
}
.padding(.vertical, 6)
}
}
// MARK: - Task list
struct ColorBlindFriendlyTaskList: View {
@State private var tasks: [Task] = [
Task(title: "Write unit tests", priority: .high),
Task(title: "Update changelog", priority: .medium),
Task(title: "Archive old screenshots", priority: .low),
Task(title: "Fix login crash", priority: .high),
Task(title: "Bump SDK version", priority: .medium),
]
var body: some View {
NavigationStack {
List(tasks) { task in
TaskRow(task: task)
}
.navigationTitle("Tasks")
}
}
}
// MARK: - Preview
#Preview("Default") {
ColorBlindFriendlyTaskList()
}
#Preview("Differentiate Without Color") {
ColorBlindFriendlyTaskList()
.environment(\.accessibilityDifferentiateWithoutColor, true)
}
How it works
-
Environment key
\.accessibilityDifferentiateWithoutColor— SwiftUI injectstrueinto the environment tree when the system accessibility setting is active. Declaring it with@Environmentmeans the view automatically re-renders whenever the user toggles the setting, with zero imperative code. -
Symbol swap in
PriorityBadge— The ternary inImage(systemName: differentiates ? priority.symbol : "circle.fill")replaces an ambiguous filled circle with a semantically distinct SF Symbol (arrow.down.circle.fill,minus.circle.fill,exclamationmark.circle.fill) so shape alone conveys priority even if hues are indistinguishable. - Text label alongside the symbol — When differentiation is active, the priority word also appears next to the symbol. This belt-and-braces approach is especially helpful for monochromacy (complete colour blindness) where neither hue nor luminance difference carries meaning.
-
.accessibilityElement(children: .ignore)+.accessibilityLabel— Collapsing the badge's child elements into a single VoiceOver element prevents the icon name ("arrow down circle fill") from leaking into the spoken description; VoiceOver reads the clean label "Priority: High" instead. -
color.opacity(0.12)background — The tinted capsule still uses the semantic color so users with partial color vision get the extra hue cue without relying on it exclusively. This layered approach (shape + label + color) is the WCAG 1.4.1 "Use of Color" compliant pattern.
Variants
Chart legend with pattern fills
When rendering charts, swap solid color fills for pattern-based fills using ImagePaint so bars and slices stay distinct in grayscale or under deuteranopia simulation.
struct PatternBar: View {
let value: Double
let color: Color
let patternName: String // e.g. "stripe_pattern", "dot_pattern"
@Environment(\.accessibilityDifferentiateWithoutColor) var differentiates
var body: some View {
Rectangle()
.fill(differentiates
? AnyShapeStyle(ImagePaint(image: Image(patternName), scale: 0.5))
: AnyShapeStyle(color))
.frame(width: 32, height: value * 2)
.overlay(
Rectangle()
.strokeBorder(color, lineWidth: 1.5)
)
.accessibilityLabel("Bar: \(Int(value)) units")
}
}
Force the setting on in Xcode Previews
You can verify both rendering paths without touching a device by injecting the environment value directly in your #Preview:
#Preview("Deuteranopia simulation") {
ColorBlindFriendlyTaskList()
.environment(\.accessibilityDifferentiateWithoutColor, true)
}
// In the Xcode Canvas you can also use:
// Editor → Canvas → Accessibility Preview → Differentiate Without Color
Common pitfalls
-
⚠️ iOS version:
accessibilityDifferentiateWithoutColorwas introduced in iOS 14, but the@Environmentkey path is only available as\.accessibilityDifferentiateWithoutColor— not\.differentiateor any shortened form. Double-check autocomplete; a typo silently defaults tofalse. -
⚠️ Color alone is still not WCAG-compliant: Even if you never touch
accessibilityDifferentiateWithoutColor, WCAG 1.4.1 requires that information conveyed by color is also conveyed by another visual means (shape, pattern, text). Don't gate the accessible rendering path solely behind that boolean — add shapes and labels unconditionally where feasible. -
⚠️ SF Symbol rendering mode matters: Some multicolor symbols look identical under deuteranopia because the two tones are red and green. Prefer
.hierarchicalor.monochromerendering modes combined with a single explicit.foregroundStylecolor rather than relying on the automatic multicolor palette.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement color blind friendly UI in SwiftUI for iOS 17+. Use Symbol variants and accessibilityDifferentiateWithoutColor. Make it accessible (VoiceOver labels, .accessibilityElement(children: .ignore)). Add a #Preview with realistic sample data and a second #Preview with .environment(\.accessibilityDifferentiateWithoutColor, true).
Paste this into Soarias during the Build phase after your screens are scaffolded — the agent will apply the pattern across all color-coded indicators in one pass.
Related
FAQ
Does this work on iOS 16?
Yes — accessibilityDifferentiateWithoutColor has been available as an @Environment key since iOS 14. The code in this guide compiles and runs on iOS 16 without modification. The #Preview macro requires Xcode 15+ but PreviewProvider is a drop-in fallback for older toolchains.
Which types of color blindness does this cover?
The shape + label pattern covers all three main categories: red-green (deuteranopia / protanopia, affecting ~8% of males), blue-yellow (tritanopia, rarer), and complete achromatopsia. Because you add a distinct symbol and a text label, meaning survives even when every hue appears as the same luminance value. For best results, also run Xcode's built-in Color Blind simulator (Product → Scheme → Run → Diagnostics → Accessibility Inspector → Color Filters) to test all filter modes.
What's the UIKit equivalent?
In UIKit, read UIAccessibility.shouldDifferentiateWithoutColor (a static Bool property) and observe the UIAccessibility.differentiateWithoutColorDidChangeNotification notification to update your views. Swap your UIImageView image and UILabel text inside the notification handler the same way SwiftUI does it reactively via environment.
Last reviewed: 2026-05-11 by the Soarias team.