Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 053 / 100

Dark mode in iOS: Swift & iOS best practices explained

Look great in both appearances.

Expona Admin6 min read

Dark mode in iOS: Swift & iOS best practices explained

Users expect your app to adapt gracefully when they toggle dark mode in their iOS device settings. Getting this wrong means your carefully designed interface becomes difficult to read or breaks entirely in one appearance. Dark mode in iOS: Swift & iOS best practices explained is about making smart choices at design time so that both light and dark interfaces feel polished without maintaining two separate codebases.

Why dark mode matters in production apps

We've all shipped features that looked pristine in Xcode previews only to discover they're barely readable on a user's phone set to dark mode. The problem compounds when you realize you need to go back and audit every color reference across your codebase. Teams working on consumer-facing apps—whether e-commerce platforms, travel apps, or SaaS dashboards—see real engagement gains when dark mode is done well. Users who prefer dark mode often keep it on all day; breaking their experience means forcing them into light mode or, worse, uninstalling.

From a technical perspective, the iOS platform gives us excellent built-in support for adapting to appearance changes. UIKit has had trait collection support for years, and SwiftUI makes it even more seamless with the @Environment property wrapper. The challenge isn't capability; it's discipline and planning.

Use semantic colors

The foundation of maintainable dark mode support is semantic color naming. Instead of naming colors after their light mode values—like buttonBlue or darkGray—we name them by their purpose. A semantic color like labelPrimary means "the main text color," which the system maps to black in light mode and white in dark mode automatically.

iOS provides several built-in semantic colors through UIColor and Color in SwiftUI:

  • Color.primary – main text and foreground elements
  • Color.secondary – secondary text and subtle UI
  • Color.tertiary – even lighter text, for hints or captions
  • Color.quaternary – minimal emphasis
  • Color.background – main background fill
  • Color.secondaryBackground – grouped or nested background areas

In a real e-commerce app, imagine you're building a product listing screen. The product name should use Color.primary, the price might use Color.secondary, and a "in stock" label might use Color.tertiary. When users switch to dark mode, these adapt automatically.

swift
VStack(alignment: .leading) {
    Text("Premium Wireless Headphones")
        .font(.headline)
        .foregroundColor(.primary)
    
    Text("$149.99")
        .font(.subheadline)
        .foregroundColor(.secondary)
    
    Text("In stock, ships in 2 days")
        .font(.caption)
        .foregroundColor(.tertiary)
}

The semantic approach scales beautifully. When product owners ask for contrast adjustments or accessibility improvements, you update the system-level definition once, and it propagates everywhere.

Asset catalog color variants

For brand colors or custom palettes that aren't covered by semantic colors, the asset catalog is your best tool. Modern Xcode lets you define color sets with separate variants for light and dark appearances, right in the UI without touching code.

To add a custom color to your asset catalog:

  1. Open your app's asset catalog (typically Assets.xcassets)
  2. Click the "+" button and select "Color Set"
  3. Name it semantically, e.g., accentBrand or cardBackground
  4. In the Attributes Inspector, set "Appearances" to "Any, Dark"
  5. Fill in the light and dark variants in the input fields

Once defined, you reference the color in code without checking appearance yourself:

swift
// SwiftUI
Text("Exclusive Offer")
    .foregroundColor(Color("accentBrand"))

// UIKit
let label = UILabel()
label.textColor = UIColor(named: "accentBrand")

Consider a ride-hailing app where your brand accent is a vibrant teal in light mode but needs to be slightly muted in dark mode to prevent eye strain. Defining these separately in the asset catalog means your design team controls both variants, and developers just reference the semantic name.

A practical tip: if you're migrating existing code, create a small map of your hardcoded colors to semantic names first. This prevents the chaos of having five different shades of gray scattered through your codebase.

Preview both schemes in Xcode

We can't rely on the simulator or our physical device to catch every dark mode edge case. SwiftUI previews make it trivial to see both appearances side-by-side during development, catching issues before they reach QA.

Use the preferredColorScheme(_:) modifier to preview specific appearance modes:

swift
struct ProductCardPreview: PreviewProvider {
    static var previews: some View {
        Group {
            ProductCard(
                title: "Travel Package: Tokyo",
                price: "$2,199",
                rating: 4.8
            )
            .preferredColorScheme(.light)
            .previewDisplayName("Light")
            
            ProductCard(
                title: "Travel Package: Tokyo",
                price: "$2,199",
                rating: 4.8
            )
            .preferredColorScheme(.dark)
            .previewDisplayName("Dark")
        }
    }
}

This lets you iterate on both modes simultaneously without switching system settings. For teams using CI/CD, you can even screenshot both variants and compare them to catch regressions.

A stronger approach for complex screens is to create a wrapper view that shows multiple regions of your UI at once:

swift
struct DarkModeTestWrapper<Content: View>: View {
    let content: Content
    
    var body: some View {
        HStack(spacing: 0) {
            content
                .preferredColorScheme(.light)
                .frame(maxWidth: .infinity)
            
            Divider()
            
            content
                .preferredColorScheme(.dark)
                .frame(maxWidth: .infinity)
        }
    }
}

Wrap any screen in DarkModeTestWrapper during development, and you see light and dark side-by-side. It's a small investment that prevents embarrassing screenshots in App Store reviews.

Avoid hard-coded colors

A common pattern we see in production code is checking for dark mode and applying colors conditionally:

swift
// DON'T do this
@Environment(\.colorScheme) var colorScheme

var textColor: Color {
    if colorScheme == .dark {
        return Color.white
    } else {
        return Color.black
    }
}

This approach duplicates logic across your codebase and makes it fragile. If the design changes, you're hunting through dozens of conditional blocks. It also prevents the system from applying accessibility overrides or future appearance modes.

Instead, let the system handle appearance switching through semantic colors and asset catalog variants. The only time you need to check colorScheme is for truly appearance-specific behavior—like adjusting blur intensity, shadow opacity, or animation timing—not for color selection.

A legitimate use case might look like this:

swift
@Environment(\.colorScheme) var colorScheme

var shadowRadius: CGFloat {
    // Shadows are more visible in dark mode, so we use less blur
    colorScheme == .dark ? 4 : 8
}

In a CRM dashboard, you might adjust chart gradient intensity based on appearance. But your chart colors should still come from semantic definitions, not from hardcoded checks.

Common mistakes

Using Color.white and Color.black directly: These don't adapt to dark mode. Always prefer semantic colors. If you need a specific shade, define it as a custom color in the asset catalog with both variants.

Forgetting to test on physical hardware: The simulator is convenient, but system colors sometimes render differently on older devices or with certain accessibility settings enabled. Run your app on real iPhones in both modes.

Assuming status bar icons are readable: Your app's content might look fine, but if the status bar (battery, time, signal) blends into your background, users will complain. Test the full screen, including system UI.

Not considering text contrast ratios: A color pair that looks fine in light mode might fail WCAG contrast requirements in dark mode. Use tools like the WebAIM contrast checker, or check Xcode's accessibility inspector.

Hardcoding appearance checks throughout the view hierarchy: Every if colorScheme == .dark is technical debt. Consolidate color logic in one place—either in asset catalog definitions or a centralized color manager.

Key takeaways

  • Use semantic colors like Color.primary and Color.secondary as your first choice; they adapt automatically and scale well as your app grows.
  • For brand or custom colors, define them in the asset catalog with separate light and dark variants. This keeps design intent visible and maintainable.
  • Preview both light and dark modes in Xcode using preferredColorScheme(_:). Catch issues early, before they ship.
  • Avoid hardcoding colors or sprinkling appearance checks throughout your code. The system handles switching for you—use it.
  • Check contrast ratios and test on physical devices. Appearances can render differently across hardware, and accessibility failures in dark mode are particularly noticeable.
  • Consistency between light and dark modes builds user trust. Jarring color choices or illegible text will drive uninstalls.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles