Articles Projects About Subscribe

Dark Mode Color Contrast Checks for SwiftUI

Dark mode regressions rarely show up as crashes; they show up as unreadable text on blurred cards, brand tints that disappear on images, or screenshots that fail contrast checks. I’ve shipped these bugs. The common thread: contrast was checked against a theoretical background, not the one the GPU actually composited.

Here’s a practical approach to make dark mode contrast checks routine, fast, and reliable.

All code in this article targets iOS 26+ and Swift 6.2 unless noted otherwise.

Why This Matters For iOS Teams

Contrast issues can surface in reviews, audits, and user reports—especially when Increase Contrast is enabled in accessibility settings. The same layout that passes on a light background can fail on a Material stack in dark mode after opacity and vibrancy adjustments. Simulator rendering may differ from real devices during dynamic interactions, so a view that looks acceptable in a screenshot can become hard to read on-device while scrolling.

If you don’t validate against the final composited background on real hardware, you may ship regressions.

1. Build A Role-Driven Color System

Tokenize Roles, Not Hex Values

Avoid sprinkling Color(red:green:blue:) across views. Create named Asset Catalog colors for Any/Dark (and High Contrast variants for Increased Contrast) mapped to semantic roles: Background, Surface, TextPrimary, TextOnSurface, and SurfaceBorder. Use them via Color(“…”). Bridge OS roles like Color(uiColor: .label) selectively.

If a color represents a role, it should be a token—otherwise you won’t converge on predictable contrast.

Anti-pattern vs preferred:

  • ❌ Hard-coded RGBs and ad-hoc alphas in views.
  • ✅ Role tokens backed by Asset Catalog entries for Any/Dark plus High Contrast.

If a Dark appearance is missing for a named color, it typically falls back to the Any appearance. Keep color spaces consistent (sRGB or Display P3) and verify assets on a physical wide-gamut device.

Wire Tokens In Views

import SwiftUI

struct AccountCard: View {
    var body: some View {
        ZStack {
            Color("Background").ignoresSafeArea()

            VStack(alignment: .leading, spacing: 12) {
                Text("Account")
                    .font(.title2.weight(.semibold))
                    .foregroundColor(Color("TextPrimary"))

                Text("Balance: $1,234")
                    .foregroundColor(Color("TextSecondary"))
            }
            .padding()
            .background(.ultraThinMaterial)
            .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
            .overlay {
                RoundedRectangle(cornerRadius: 16, style: .continuous)
                    .strokeBorder(Color("SurfaceBorder"), lineWidth: 1)
            }
        }
    }
}

Decision: Keep text at full opacity and tune the surface. Stacking semi-transparent text over Material plus a tinted Surface can compose darker than expected and reduce contrast.

Reserve system semantic colors for OS-owned surfaces (status bars, navigation bars, menus) to avoid clashes with brand ramps in dark mode.

2. Make Contrast A Debug-Time Invariant

Luminance Math Utility

You don’t need a frame-by-frame watchdog in release. Add a small, unit-tested helper to compute WCAG-style contrast for the resolved colors you actually draw.

import SwiftUI
import UIKit

enum Contrast {
    static func relativeLuminance(_ c: UIColor) -> CGFloat {
        var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
        guard c.getRed(&r, green: &g, blue: &b, alpha: &a) else { return 0 }
        func adjust(_ x: CGFloat) -> CGFloat {
            x <= 0.03928 ? (x / 12.92) : pow((x + 0.055) / 1.055, 2.4)
        }
        let R = adjust(r), G = adjust(g), B = adjust(b)
        return 0.2126 * R + 0.7152 * G + 0.0722 * B
    }

    static func ratio(foreground: UIColor, background: UIColor) -> CGFloat {
        let L1 = relativeLuminance(foreground)
        let L2 = relativeLuminance(background)
        let (lighter, darker) = (max(L1, L2), min(L1, L2))
        return (lighter + 0.05) / (darker + 0.05)
    }
}

Guard Surfaces With A Modifier

Add a debug-only ViewModifier that asserts when a known surface (list row, card, alert) falls short. Use Logger to make failures visible during development without affecting release logs.

import SwiftUI
import os

struct ContrastGuard: ViewModifier {
    @Environment(\.colorSchemeContrast) private var contrast

    var foreground: Color
    var background: Color
    var label: String

    func body(content: Content) -> some View {
        #if DEBUG
        let fg = UIColor(foreground)
        let bg = UIColor(background)
        let ratio = Contrast.ratio(foreground: fg, background: bg)

        // Project defaults: tune these to your typography and policy
        let target: CGFloat = (contrast == .increased) ? 7.0 : 4.5

        if ratio < target {
            Logger(subsystem: "ui.contrast", category: "debug")
                .error("Contrast fail (\(label)): ratio=\(ratio, format: .fixed(2)) target=\(target)")
            assertionFailure("Contrast fail (\(label)): \(ratio) < \(target)")
        }
        #endif
        return content
    }
}

extension View {
    func debugContrast(
        foreground: Color,
        background: Color,
        label: String
    ) -> some View {
        modifier(ContrastGuard(foreground: foreground, background: background, label: label))
    }
}

Decision: Assert in debug at well-defined hotspots; skip runtime checks in release to avoid unnecessary overhead.

For image-backed Material, compute against the actual solid you place under it (e.g., a known Surface color), then perform a manual Accessibility Inspector pass on-device. Pure math does not account for vibrancy and blur.

3. Check The Background You Actually Draw

Materials, Vibrancy, and Transparency

Material blends with whatever is underneath it. If the underneath is an image, a math-only check on Color(“Surface”) is not representative. Either ensure a solid fallback under the Material, or compute against that fallback for deterministic checks.

import SwiftUI

struct CardSurface: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Title")
                .font(.headline)
                .foregroundColor(Color("TextOnSurface")) // keep at alpha 1.0
            Text("Subtitle")
                .foregroundColor(Color("TextSecondary"))
        }
        .padding(16)
        .background(
            Color("Surface") // solid fallback ensures predictable contrast math
                .opacity(0.85)
                .background(.ultraThinMaterial) // keep the look, but math uses Surface
        )
        .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
    }
}

Decision: When you need deterministic checking, use solid-under-material. When you accept manual validation and variability with imagery, use pure Material.

Respect .colorSchemeContrast. In Increased Contrast, prefer higher-contrast token variants and consider bumping Surface opacity to reduce bleed-through without touching text opacity.

4. Preview Fast, Verify In Real Navigation

SwiftUI Previews For Iteration

Use previews to iterate on tokens and Increased Contrast quickly. Validate with both dark and increased-contrast environments.

Hosting Window Overrides For UIKit Bridges

Some failures emerge only when bars and titles inherit UIKit appearance. Probe real navigation stacks and titles by overriding on the hosting UIWindow.

import UIKit

final class AppearanceProbe {
    static func applyDark(to window: UIWindow) {
        window.overrideUserInterfaceStyle = .dark
    }
}

Decision: Use previews for component surfaces. Validate navigation bars, large titles, and tab bars in a real flow on-device. A title that passes on a card may fail once UINavigationController blur and brand tints apply.

Snapshot diffs are device- and scale-sensitive. Include at least one physical device in your review loop.

5. Unit And UI Tests That Stick

Deterministic Pairs In XCTest

Lock down your Contrast utility with unit tests so regressions surface immediately.

import XCTest
import UIKit
import CoreGraphics
import CoreImage

struct Contrast {
    static func ratio(foreground: UIColor, background: UIColor) -> Double {
        func rgb(_ color: UIColor) -> (Double, Double, Double) {
            var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
            if color.getRed(&r, green: &g, blue: &b, alpha: &a) {
                return (Double(r), Double(g), Double(b))
            }
            if let sRGB = CGColorSpace(name: CGColorSpace.sRGB),
               let conv = color.cgColor.converted(to: sRGB, intent: .relativeColorimetric, options: nil),
               let comps = conv.components, comps.count >= 3 {
                return (Double(comps[0]), Double(comps[1]), Double(comps[2]))
            }
            let ci = CIColor(color: color)
            return (Double(ci.red), Double(ci.green), Double(ci.blue))
        }
        func relLuminance(_ color: UIColor) -> Double {
            let (r, g, b) = rgb(color)
            func toLinear(_ c: Double) -> Double { c <= 0.03928 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) }
            let R = toLinear(r), G = toLinear(g), B = toLinear(b)
            return 0.2126 * R + 0.7152 * G + 0.0722 * B
        }
        let L1 = relLuminance(foreground)
        let L2 = relLuminance(background)
        let (high, low) = L1 >= L2 ? (L1, L2) : (L2, L1)
        return (high + 0.05) / (low + 0.05)
    }
}

final class ContrastTests: XCTestCase {
    func testBlackOnWhitePassesInIncreasedContrast() {
        let ratio = Contrast.ratio(foreground: .black, background: .white)
        XCTAssertGreaterThanOrEqual(ratio, 7.0)
    }

    func testGrayOnGrayFailsInNormalContrast() {
        let ratio = Contrast.ratio(
            foreground: UIColor(white: 0.6, alpha: 1),
            background: UIColor(white: 0.5, alpha: 1)
        )
        XCTAssertLessThan(ratio, 4.5)
    }
}```

### Snapshot Only Where Stable
When the surface is text-on-solid and deterministic, use snapshots. When Material and imagery are involved, use a manual device check. Gate debug-only Logger messages so contributors see violations without digging through CI.

If contrast checks flap during scroll, inspect state ownership. Excessive view invalidations can mask real issues; stabilize state first, then read your contrast signals.

## Tradeoffs And Pitfalls
- System colors like Color(uiColor: .label) adapt well, but they can clash with brand ramps. Use system colors for OS-owned chrome and brand tokens for content.
- Math cannot see vibrancy, blur, or images. Treat it as a guardrail, not a verdict.
- Overusing runtime checks in release can add overhead and increase logging noise. Keep assertions and detailed logs in debug.
- Mixed color spaces are a silent failure mode. Keep assets consistent and verify on a wide-gamut device.
- Semi-transparent text is fragile on dark Material. Prefer solid text with adjusted backgrounds for reliable contrast.

## Validation & Observability
- Tests that encode the rule: Keep XCTest covering common foreground/background pairs, including brand tokens. Fail fast on ratio regressions.
- Device-first verification: Use a physical device for imagery and Material. Simulator may not reflect composition and rendering nuances under interaction.
- Structured logging: Use Logger categories like ui.contrast and emit label, ratio, and target so violations are triageable.
- Performance correlation: Use signposts to correlate scroll, re-render bursts, and contrast logs in Instruments Time Profiler. If checks flap under load, re-evaluate state ownership and view invalidation paths.
- Rollout gates: Hide high-risk screens behind an internal toggle that enables additional debug logs on tap. Remove before release candidate builds.
- Post-release signals: QA with Increased Contrast enabled, and collect annotated screenshots from support to reproduce setting-specific failures quickly.

## Practical Checklist
- [ ] Replace hard-coded RGBs with Asset Catalog role tokens for Any/Dark, plus High Contrast for Increased Contrast.
- [ ] Normalize asset color spaces (sRGB or Display P3) and validate on a wide-gamut device.
- [ ] Add a Contrast utility and unit tests for representative token pairs.
- [ ] Introduce a debug-only ViewModifier to assert contrast on list rows, cards, and alerts.
- [ ] Keep text at alpha 1.0; adjust Surface opacity or layering to hit contrast targets.
- [ ] Validate Material and imagery with device runs and the Accessibility Inspector.
- [ ] Exercise previews with dark and increased contrast environments; verify bars/titles in a real navigation flow.
- [ ] Pipe Logger messages to make violations visible during development; avoid release logging noise.

## Closing Takeaway
Treat dark mode contrast as an invariant you encode, not a vibe you eyeball. Tokenize colors by role and compute contrast for deterministic surfaces in code. Where composition mattersMaterial, imagery, vibrancyadd solid fallbacks or validate on-device with intent. Keep the checks in debug, connect them to real navigation, and stabilize state so your signals are trustworthy. Do this, and dark mode becomes a property of your system you can ship with confidence.

## Swift/SwiftUI Code Example

```swift
import SwiftUI
import UIKit
import CoreGraphics
import Foundation

private func luminance(_ c: UIColor) -> Double {
    var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
    c.getRed(&r, green: &g, blue: &b, alpha: &a)
    func adjust(_ v: CGFloat) -> Double {
        let v = Double(v)
        return v <= 0.03928 ? v/12.92 : pow((v+0.055)/1.055, 2.4)
    }
    return 0.2126*adjust(r) + 0.7152*adjust(g) + 0.0722*adjust(b)
}

private func contrastRatio(_ fg: UIColor, _ bg: UIColor) -> Double {
    let L1 = luminance(fg) + 0.05, L2 = luminance(bg) + 0.05
    return max(L1, L2) / min(L1, L2)
}

private struct WindowProbe: UIViewRepresentable {
    final class ViewBox: UIView {
        var onSnapshot: ((UIWindow, CGRect) -> Void)?
        override func layoutSubviews() {
            super.layoutSubviews()
            if let w = window {
                let rect = convert(bounds, to: w)
                onSnapshot?(w, rect)
            }
        }
    }
    var onSnapshot: (UIWindow, CGRect) -> Void
    func makeUIView(context: Context) -> ViewBox {
        let v = ViewBox()
        v.isUserInteractionEnabled = false
        v.onSnapshot = onSnapshot
        return v
    }
    func updateUIView(_ uiView: ViewBox, context: Context) {}
}

private func centerPixelColor(in image: UIImage) -> UIColor {
    guard let cg = image.cgImage else { return .clear }
    let w = cg.width, h = cg.height, x = w/2, y = h/2
    guard let data = cg.dataProvider?.data as Data? else { return .clear }
    let bpr = cg.bytesPerRow, bpp = 4
    let idx = y*bpr + x*bpp
    if idx + 3 < data.count {
        let r = CGFloat(data[idx]) / 255.0
        let g = CGFloat(data[idx+1]) / 255.0
        let b = CGFloat(data[idx+2]) / 255.0
        let a = CGFloat(data[idx+3]) / 255.0
        return UIColor(red: r, green: g, blue: b, alpha: a)
    }
    return .clear
}

@MainActor
struct ContrastBadge: ViewModifier {
    var foreground: Color
    var threshold: Double = 4.5
    @State private var ratio: Double?
    func body(content: Content) -> some View {
        content
            .overlay(alignment: .topTrailing) {
                if let r = ratio {
                    Text(String(format: "%.2f×", r))
                        .font(.caption2.monospacedDigit())
                        .padding(4)
                        .background(Capsule().fill(r >= threshold ? .green.opacity(0.75) : .red.opacity(0.75)))
                        .foregroundStyle(.white)
                        .padding(4)
                        .accessibilityLabel("Contrast ratio \(Int(r*100)/100)")
                }
            }
            .background(
                WindowProbe { window, rect in
                    let format = UIGraphicsImageRendererFormat()
                    format.scale = window.screen.scale
                    let renderer = UIGraphicsImageRenderer(bounds: rect, format: format)
                    let img = renderer.image { _ in
                        window.drawHierarchy(in: rect, afterScreenUpdates: false)
                    }
                    let bg = centerPixelColor(in: img)
                    let fg = UIColor(foreground)
                    ratio = contrastRatio(fg, bg)
                }
            )
    }
}

extension View {
    func a11yContrastBadge(foreground: Color, threshold: Double = 4.5) -> some View {
        modifier(ContrastBadge(foreground: foreground, threshold: threshold))
    }
}

struct MaterialCardRow: View {
    var title: String
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 16).fill(.ultraThinMaterial).shadow(radius: 4)
            Text(title).font(.headline).foregroundStyle(.primary)
                .a11yContrastBadge(foreground: .primary)
                .padding()
        }.padding()
    }
}

References