Skip to content

Adapt for Dark Mode in SwiftUI project | SwiftUI Bootcamp #44

Dark mode support is no longer optional — Apple's Human Interface Guidelines require it, and users actively toggle between modes. After this lesson you'll understand the four techniques for adaptive coloring in SwiftUI: semantic system colors, @Environment(\.colorScheme) for inline logic, named Asset Catalog colors, and the .preferredColorScheme modifier for testing both modes in Xcode Previews.

What You'll Learn

  • How semantic colors like .primary and .secondary automatically adapt without any extra code
  • How to read the current color scheme using @Environment(\.colorScheme) and branch on it inline
  • How to define a named "adaptive color" in the Asset Catalog that has separate light and dark values
  • How to preview both light and dark mode simultaneously using .preferredColorScheme in PreviewProvider

Mental Model

Think of SwiftUI colors like a clothing store. Some items — .primary, .secondary — are already "reversible" garments that look correct on both a white background (light mode) and a black background (dark mode). The store designed them to work in either setting, no extra work needed.

Other items are fixed colors — .black always looks black, .red always looks red. On a white background these are fine, but .black text on a dark background is invisible. You, the developer, are responsible for knowing when to use reversible colors vs. fixed ones.

Asset Catalog named colors are like custom reversible garments you design yourself: you pick which color appears in light mode and which appears in dark mode. SwiftUI reads the right swatch automatically. And @Environment(\.colorScheme) is like a tag sensor in the store — you can read whether the current environment is "day" or "night" and choose any garment you want based on that signal.

Detailed Explanation

SwiftUI has two categories of color: semantic (adaptive) and concrete (fixed). Semantic colors — .primary, .secondary, .background, .label — are defined by the OS and automatically invert or adjust when the user switches appearance. Concrete colors — .black, .white, .red, .blue — are exact color values that never change. Using concrete colors for text and backgrounds is the most common source of dark mode failures.

@Environment(\.colorScheme) gives your view a live reference to the current appearance setting. When the user changes their device appearance, SwiftUI rebuilds views that read this environment value, so inline conditions like colorScheme == .light ? .green : .yellow update automatically. This is the right approach for one-off color choices that don't warrant a dedicated Asset Catalog entry.

For colors you reuse across multiple views — brand colors, semantic background shades, status indicator colors — the Asset Catalog approach is better. In Xcode, open Assets.xcassets, create a "Color Set", and provide separate hex values for the Light and Dark appearances. You reference it in code as Color("AdaptiveColor"). The OS selects the correct swatch at runtime with zero conditional logic in your view.

When to use each approach: use semantic system colors (.primary, .secondary) for the vast majority of text and secondary content. Use Asset Catalog colors for brand and feature-specific colors. Use @Environment(\.colorScheme) sparingly, only when the logic cannot be expressed as a simple color swap (e.g., changing opacity, choosing between two entirely different images, or making structural layout changes based on appearance).

Code Structure

DarkModeBootcamp.swift displays seven text labels, each demonstrating a different coloring technique: two semantic colors, two concrete colors, a custom named Asset Catalog color, and an @Environment-driven inline color. The PreviewProvider uses .preferredColorScheme to render both appearances in a Group, so you can compare them side by side in the Xcode canvas.

Complete Code

DarkModeBootcamp.swift

swift
import SwiftUI

struct DarkModeBootcamp: View {
    
    @Environment(\.colorScheme) var colorScheme // live reference to the current appearance (.light or .dark)
    
    var body: some View {
        NavigationView {
            ScrollView {
                VStack(spacing: 20) {
                    
                    Text("This color is PRIMARY")
                        .foregroundColor(.primary)    // adapts automatically: near-black in light, near-white in dark
                    Text("This color is SECONDARY")
                        .foregroundColor(.secondary)  // muted adaptive color: dark gray in light, mid-gray in dark
                    Text("This color is BLACK")
                        .foregroundColor(.black)      // always black — invisible on dark backgrounds!
                    Text("This color is WHITE")
                        .foregroundColor(.white)      // always white — invisible on light backgrounds!
                    Text("This color is RED")
                        .foregroundColor(.red)        // concrete color; stays red in both modes (usually acceptable)
                    Text("This color is globally adaptive!")
                        .foregroundColor(Color("AdaptiveColor")) // reads a named color from Assets.xcassets with separate light/dark values
                    Text("This color is locally adaptive!")
                        .foregroundColor(colorScheme == .light ? .green : .yellow) // inline branch on the environment value
                    
                }
            }
            .navigationTitle("Dark Mode Bootcamp")
        }
    }
}

struct DarkModeBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            DarkModeBootcamp()
                .preferredColorScheme(.light)  // forces this preview instance to render in light mode
            DarkModeBootcamp()
                .preferredColorScheme(.dark)   // forces this preview instance to render in dark mode
        }
    }
}

Code Walkthrough

  1. @Environment(\.colorScheme) var colorScheme — Subscribes this view to the system appearance. When the user toggles dark mode, SwiftUI invalidates and re-renders any view that reads this value. It costs nothing if you don't use it, so it's fine to declare defensively.

  2. .foregroundColor(.primary) — Uses the OS-defined semantic "primary" color. In light mode this is nearly black; in dark mode it is nearly white. This is the correct default for body text — it always passes contrast requirements.

  3. .foregroundColor(.secondary) — The OS-defined secondary text color. Slightly lighter than primary, used for captions, supporting text, and placeholders. Also adapts automatically.

  4. .foregroundColor(.black) and .foregroundColor(.white) — These are concrete colors that never change. Notice that .black text on a dark background is unreadable — this is the most common dark mode bug. Reserve .black and .white for decorative elements where visibility in both modes is intentional.

  5. Color("AdaptiveColor") — Looks up a named color set from Assets.xcassets. The image set must have separate color values for "Any Appearance" (light) and "Dark" variants. The OS picks the correct value at runtime. This is the recommended approach for any color used in more than one place.

  6. colorScheme == .light ? .green : .yellow — An inline ternary that reads the current environment value. This works correctly because colorScheme is a reactive @Environment value — the view re-evaluates whenever it changes.

  7. PreviewProvider Group with two .preferredColorScheme variants — Renders the view twice, once forced to light and once forced to dark, side by side in the Xcode canvas. This makes it trivial to spot dark mode bugs before running on a device. You can also add .preferredColorScheme(.dark) to any View at runtime to force a specific appearance regardless of system settings.

Common Mistakes

Mistake: Using .black or .white for text or backgrounds and being surprised when they are invisible in one mode
Always reach for .primary and .secondary first. If you need a specific shade, create an Asset Catalog color with distinct light and dark values. Never use hard-coded .black or .white for text that must be readable in both modes.

Mistake: Forgetting to add the "Dark" appearance variant when creating a named color in Assets.xcassets
If you create a Color Set in Assets.xcassets and only fill in "Any Appearance", the color is the same in both modes. You must explicitly set the "Dark" appearance value. Xcode will not warn you if the dark variant is missing — the color simply won't change in dark mode.

Mistake: Using @Environment(\.colorScheme) for every color when Asset Catalog adaptive colors would be simpler
@Environment(\.colorScheme) is for conditional logic. For pure color swaps, an Asset Catalog named color is cleaner, centralizes the color definition, supports the design workflow (designers can define the values), and requires no conditional code in the view.

Key Takeaways

  • Use .primary and .secondary by default for all text — they are already adaptive and require zero extra code.
  • Define brand and feature colors as named Color Sets in Assets.xcassets with explicit Dark appearance variants, then reference them with Color("YourColorName").
  • Add .preferredColorScheme(.light) and .preferredColorScheme(.dark) to your PreviewProvider to validate dark mode correctness before running on a real device.

Last updated: June 27, 2026

Released under the MIT License.