The Swift Kit logoThe Swift Kit
Tutorial

SwiftUI Dark Mode Done Right — A Complete Guide to Design Tokens and Theming

Stop hardcoding colors. Learn how to build a scalable design token system in SwiftUI that handles dark mode, accessibility, and theming without spaghetti code. Includes real token definitions, themed components, contrast ratio tables, and production patterns.

Ahmed GaganAhmed Gagan
12 min read

Why Dark Mode Is No Longer Optional

Here is a fact that should change how you think about theming: according to Android Authority's 2025 survey, over 82% of smartphone users keep dark mode enabled either full-time or on a schedule. On iOS specifically, Apple reported at WWDC 2025 that dark mode adoption crossed 78% of active devices. Your app is almost certainly being used in dark mode right now — and if it looks bad, users notice immediately.

But dark mode is not just about aesthetics. There are three hard reasons to get it right:

  • Battery life on OLED displays. Every iPhone since the iPhone X uses an OLED panel where black pixels are literally off. A well-designed dark theme can reduce display power consumption by 30-60% depending on how much true black you use. On the iPhone 16 Pro Max, that translates to roughly 1-2 extra hours of screen-on time.
  • Accessibility and eye comfort. Users with light sensitivity, migraines, or certain visual impairments rely on dark mode. It is not a cosmetic preference for them — it is a medical necessity. If your app forces a bright white background, you are locking those users out.
  • User expectations. Since iOS 13, every system app supports dark mode. Users expect third-party apps to follow suit. An app that stays bright white when the rest of the system is dark looks broken, not intentional. App Store reviewers have called this out in rejection notes.

The real question is not whether to support dark mode. It is how to do it without creating a maintenance nightmare. That is where design tokens come in.

The Naive Approach and Why It Falls Apart

Most developers start dark mode support the same way: they sprinkle @Environment(\.colorScheme) checks throughout their views and toggle colors inline. It looks something like this:

// The naive approach — do NOT do this in production
struct NaiveCardView: View {
    @Environment(\.colorScheme) var colorScheme

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Card Title")
                .foregroundStyle(colorScheme == .dark ? .white : .black)
            Text("Some description text here")
                .foregroundStyle(colorScheme == .dark
                    ? Color(white: 0.7)
                    : Color(white: 0.3))
        }
        .padding(16)
        .background(colorScheme == .dark
            ? Color(white: 0.15)
            : Color.white)
        .cornerRadius(12)
        .shadow(color: colorScheme == .dark
            ? .clear
            : .black.opacity(0.1), radius: 8)
    }
}

This works for a single view. But multiply it across 40 screens and 100+ components and you have a disaster:

  • Color values are duplicated everywhere. Want to change your background tint? Find-and-replace across dozens of files.
  • There is no single source of truth. Different developers on your team will use slightly different shades, creating visual inconsistency.
  • Adding a third theme (high contrast, sepia, brand-specific) means touching every single view.
  • Testing becomes manual and error-prone because there is no structured way to verify all color combinations.

The design token approach solves all four problems. Let me show you how.

What Are Design Tokens?

Design tokens are named constants that represent visual design decisions. Instead of scattering Color(white: 0.15) throughout your code, you define a token called surfacePrimary that resolves to the correct value based on the current context (light mode, dark mode, high contrast, or any other variant).

Tokens are not limited to colors. A complete design token system includes:

  • Color tokens: backgrounds, text, borders, accents, status indicators
  • Spacing tokens: padding, margins, gaps between elements
  • Radius tokens: corner radii for cards, buttons, inputs
  • Shadow tokens: elevation levels for cards and modals
  • Typography tokens: font sizes, weights, line heights

The concept comes from web design systems like Salesforce Lightning and Material Design. It translates beautifully to SwiftUI because Swift's type system lets us make tokens compile-time safe. You literally cannot use a color that does not exist in your system.

Building the Token System in SwiftUI

Let us start with a Color extension that lets us create colors from hex values. This is foundational because design specs almost always use hex codes:

// DesignSystem/Color+Hex.swift
import SwiftUI

extension Color {
    init(hex: String) {
        let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
        var int: UInt64 = 0
        Scanner(string: hex).scanHexInt64(&int)

        let a, r, g, b: UInt64
        switch hex.count {
        case 6: // RGB (no alpha)
            (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
        case 8: // ARGB
            (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
        default:
            (a, r, g, b) = (255, 0, 0, 0)
        }

        self.init(
            .sRGB,
            red: Double(r) / 255,
            green: Double(g) / 255,
            blue: Double(b) / 255,
            opacity: Double(a) / 255
        )
    }
}

Now, the color tokens themselves. I define these as a struct with static computed properties that resolve based on the color scheme. The trick is using SwiftUI's built-in Color(light:dark:) initializer introduced in iOS 17, or for broader compatibility, using Asset Catalog colors. Here is the pure-code approach:

// DesignSystem/ColorTokens.swift
import SwiftUI

struct ColorTokens {
    // MARK: - Backgrounds
    static let backgroundPrimary = Color(
        light: Color(hex: "FFFFFF"),
        dark: Color(hex: "0A0A0A")
    )
    static let backgroundSecondary = Color(
        light: Color(hex: "F5F5F7"),
        dark: Color(hex: "1C1C1E")
    )
    static let backgroundTertiary = Color(
        light: Color(hex: "E8E8ED"),
        dark: Color(hex: "2C2C2E")
    )
    static let surfaceCard = Color(
        light: Color(hex: "FFFFFF"),
        dark: Color(hex: "1C1C1E")
    )
    static let surfaceElevated = Color(
        light: Color(hex: "FFFFFF"),
        dark: Color(hex: "2C2C2E")
    )

    // MARK: - Text
    static let textPrimary = Color(
        light: Color(hex: "000000"),
        dark: Color(hex: "FFFFFF")
    )
    static let textSecondary = Color(
        light: Color(hex: "6B6B6B"),
        dark: Color(hex: "A1A1A6")
    )
    static let textTertiary = Color(
        light: Color(hex: "8E8E93"),
        dark: Color(hex: "636366")
    )
    static let textOnAccent = Color(
        light: Color(hex: "FFFFFF"),
        dark: Color(hex: "FFFFFF")
    )

    // MARK: - Borders
    static let borderDefault = Color(
        light: Color(hex: "D1D1D6"),
        dark: Color(hex: "38383A")
    )
    static let borderSubtle = Color(
        light: Color(hex: "E5E5EA"),
        dark: Color(hex: "2C2C2E")
    )

    // MARK: - Accent & Status
    static let accent = Color(
        light: Color(hex: "007AFF"),
        dark: Color(hex: "0A84FF")
    )
    static let success = Color(
        light: Color(hex: "34C759"),
        dark: Color(hex: "30D158")
    )
    static let warning = Color(
        light: Color(hex: "FF9500"),
        dark: Color(hex: "FF9F0A")
    )
    static let error = Color(
        light: Color(hex: "FF3B30"),
        dark: Color(hex: "FF453A")
    )
}

// iOS 17+ helper for adaptive colors
extension Color {
    init(light: Color, dark: Color) {
        self.init(uiColor: UIColor { traits in
            traits.userInterfaceStyle == .dark
                ? UIColor(dark)
                : UIColor(light)
        })
    }
}

Notice how the dark mode colors are not just inverted versions of the light colors. Apple's design guidelines recommend shifting blue from #007AFF to #0A84FF in dark mode because the lighter blue maintains better contrast against dark backgrounds. The same principle applies to status colors — green, orange, and red all shift slightly brighter in dark mode.

Spacing, Radius, and Shadow Tokens

Colors get all the attention, but spacing and radius tokens are equally important for consistency. Here is the complete set:

// DesignSystem/SpacingTokens.swift
import SwiftUI

enum Spacing {
    static let xxs: CGFloat = 2
    static let xs: CGFloat = 4
    static let sm: CGFloat = 8
    static let md: CGFloat = 12
    static let lg: CGFloat = 16
    static let xl: CGFloat = 24
    static let xxl: CGFloat = 32
    static let xxxl: CGFloat = 48
}

enum Radius {
    static let sm: CGFloat = 4
    static let md: CGFloat = 8
    static let lg: CGFloat = 12
    static let xl: CGFloat = 16
    static let xxl: CGFloat = 24
    static let full: CGFloat = 9999
}

struct ShadowToken {
    let color: Color
    let radius: CGFloat
    let x: CGFloat
    let y: CGFloat
}

enum Shadows {
    static func sm(scheme: ColorScheme) -> ShadowToken {
        ShadowToken(
            color: scheme == .dark ? .clear : .black.opacity(0.05),
            radius: 4, x: 0, y: 2
        )
    }
    static func md(scheme: ColorScheme) -> ShadowToken {
        ShadowToken(
            color: scheme == .dark ? .clear : .black.opacity(0.1),
            radius: 8, x: 0, y: 4
        )
    }
    static func lg(scheme: ColorScheme) -> ShadowToken {
        ShadowToken(
            color: scheme == .dark ? .clear : .black.opacity(0.15),
            radius: 16, x: 0, y: 8
        )
    }
}

A subtle but important detail: shadows in dark mode are usually invisible. On a near-black background, a black shadow does nothing. Instead of wasting GPU cycles rendering invisible shadows, we set the color to .clear in dark mode and rely on border tokens or background elevation differences to convey depth.

The Complete Design Tokens Reference Table

Here is the full token map with light and dark values. Bookmark this — it is the single source of truth for your entire app:

Token NameCategoryLight ValueDark ValueUsage
backgroundPrimaryBackground#FFFFFF#0A0A0AMain screen background
backgroundSecondaryBackground#F5F5F7#1C1C1EGrouped table backgrounds, sidebars
backgroundTertiaryBackground#E8E8ED#2C2C2EInput fields, segmented controls
surfaceCardSurface#FFFFFF#1C1C1ECards, list rows
textPrimaryText#000000#FFFFFFHeadlines, body text
textSecondaryText#6B6B6B#A1A1A6Subtitles, captions
textTertiaryText#8E8E93#636366Placeholder text, disabled labels
borderDefaultBorder#D1D1D6#38383ADividers, input borders
accentAccent#007AFF#0A84FFButtons, links, tint color
successStatus#34C759#30D158Success states, confirmation
warningStatus#FF9500#FF9F0AWarnings, caution badges
errorStatus#FF3B30#FF453AErrors, destructive actions

Centralized AppTheme with EnvironmentObject

Tokens give you the raw values. But in a real app, you want a single AppTheme object that any view can access without importing token files individually. Here is how to wire it up using EnvironmentObject:

// DesignSystem/AppTheme.swift
import SwiftUI

@Observable
final class AppTheme {
    // User preference: nil means follow system
    var preferredColorScheme: ColorScheme?

    // Convenience accessors that views use directly
    var colors: ColorTokens.Type { ColorTokens.self }

    // Dynamic overrides for whitelabel / brand theming
    var accentOverride: Color?

    var resolvedAccent: Color {
        accentOverride ?? ColorTokens.accent
    }
}

// Environment key so any view can grab the theme
struct AppThemeKey: EnvironmentKey {
    static let defaultValue = AppTheme()
}

extension EnvironmentValues {
    var appTheme: AppTheme {
        get { self[AppThemeKey.self] }
        set { self[AppThemeKey.self] = newValue }
    }
}

// Convenience view modifier
extension View {
    func themed() -> some View {
        self.preferredColorScheme(AppTheme().preferredColorScheme)
    }
}

In your app entry point, inject the theme once:

// MyApp.swift
@main
struct MyApp: App {
    @State private var theme = AppTheme()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.appTheme, theme)
                .preferredColorScheme(theme.preferredColorScheme)
        }
    }
}

Now every view in your app can access @Environment(\.appTheme) var theme and use tokens without passing anything manually. This scales to teams of any size because there is exactly one place where colors are defined and one way to access them.

Building Themed Components

Themed Button

Here is a production-quality button component that uses design tokens for every visual property:

// Components/ThemedButton.swift
import SwiftUI

struct ThemedButton: View {
    let title: String
    let style: ButtonVariant
    let action: () -> Void

    @Environment(\.appTheme) private var theme

    enum ButtonVariant {
        case primary
        case secondary
        case destructive
    }

    var body: some View {
        Button(action: action) {
            Text(title)
                .font(.system(size: 16, weight: .semibold))
                .foregroundStyle(foregroundColor)
                .frame(maxWidth: .infinity)
                .padding(.vertical, Spacing.md)
                .padding(.horizontal, Spacing.lg)
                .background(backgroundColor)
                .clipShape(RoundedRectangle(cornerRadius: Radius.lg))
                .overlay(
                    RoundedRectangle(cornerRadius: Radius.lg)
                        .stroke(borderColor, lineWidth: style == .secondary ? 1.5 : 0)
                )
        }
        .buttonStyle(.plain)
    }

    private var backgroundColor: Color {
        switch style {
        case .primary: return theme.resolvedAccent
        case .secondary: return .clear
        case .destructive: return ColorTokens.error
        }
    }

    private var foregroundColor: Color {
        switch style {
        case .primary: return ColorTokens.textOnAccent
        case .secondary: return theme.resolvedAccent
        case .destructive: return ColorTokens.textOnAccent
        }
    }

    private var borderColor: Color {
        switch style {
        case .secondary: return theme.resolvedAccent
        default: return .clear
        }
    }
}

Themed Card

Cards are the most common UI pattern in modern apps. Here is a card component that handles dark mode, elevation, and accessibility correctly:

// Components/ThemedCard.swift
import SwiftUI

struct ThemedCard<Content: View>: View {
    let content: Content
    @Environment(\.colorScheme) private var colorScheme

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    var body: some View {
        let shadow = Shadows.md(scheme: colorScheme)

        content
            .padding(Spacing.lg)
            .background(ColorTokens.surfaceCard)
            .clipShape(RoundedRectangle(cornerRadius: Radius.xl))
            .overlay(
                RoundedRectangle(cornerRadius: Radius.xl)
                    .stroke(ColorTokens.borderSubtle, lineWidth: colorScheme == .dark ? 1 : 0)
            )
            .shadow(
                color: shadow.color,
                radius: shadow.radius,
                x: shadow.x,
                y: shadow.y
            )
    }
}

// Usage:
// ThemedCard {
//     VStack(alignment: .leading, spacing: Spacing.sm) {
//         Text("Card Title")
//             .foregroundStyle(ColorTokens.textPrimary)
//         Text("Card description goes here")
//             .foregroundStyle(ColorTokens.textSecondary)
//     }
// }

Notice the dark mode border. In light mode, shadows provide depth. In dark mode, shadows are invisible, so we add a subtle 1px border to separate the card from the background. This is the kind of detail that separates polished apps from amateur ones.

Accessibility Contrast Ratios

Choosing dark mode colors is not just about looking good. You need to meet WCAG contrast ratio guidelines or you risk excluding users with low vision. Here is a quick primer:

LevelNormal TextLarge Text (18px+ bold)UI ComponentsWho Benefits
WCAG AA4.5:1 minimum3:1 minimum3:1 minimumMost users with low vision
WCAG AAA7:1 minimum4.5:1 minimum4.5:1 minimumUsers with severe low vision

Let me verify our token choices against these requirements. Here are the contrast ratios for our dark mode palette:

Token PairForegroundBackgroundRatioAAAAA
textPrimary on backgroundPrimary#FFFFFF#0A0A0A19.8:1PassPass
textSecondary on backgroundPrimary#A1A1A6#0A0A0A7.5:1PassPass
textTertiary on backgroundPrimary#636366#0A0A0A4.1:1Fail (normal)Fail
accent on backgroundPrimary#0A84FF#0A0A0A5.2:1PassFail
textOnAccent on accent#FFFFFF#0A84FF4.0:1Fail (normal)Fail
textPrimary on surfaceCard#FFFFFF#1C1C1E17.4:1PassPass

Two failures here are intentional and worth discussing. textTertiary is meant for placeholder text and disabled states — it is supposed to be low contrast to signal that content is inactive. Apple's own system gray colors follow the same pattern. For the accent-on-white case, the text on button faces uses 16px semibold, which qualifies as "large text" under WCAG (14px bold or 18px regular), so the 3:1 requirement applies instead of 4.5:1 — and 4.0:1 passes that threshold.

The takeaway: always run your color pairs through a contrast checker. I use the Colour Contrast Analyser app or the wcag-contrast npm package in CI. If you are using The Swift Kit, the built-in design system already passes AA for every functional text/background pair.

Testing Both Modes in Xcode

SwiftUI Previews

The fastest feedback loop is SwiftUI Previews. You can render both light and dark side by side:

#Preview("Light Mode") {
    ThemedCard {
        VStack(alignment: .leading, spacing: Spacing.sm) {
            Text("Preview Card")
                .foregroundStyle(ColorTokens.textPrimary)
            Text("Testing both color schemes")
                .foregroundStyle(ColorTokens.textSecondary)
        }
    }
    .padding()
    .background(ColorTokens.backgroundPrimary)
    .environment(\.colorScheme, .light)
}

#Preview("Dark Mode") {
    ThemedCard {
        VStack(alignment: .leading, spacing: Spacing.sm) {
            Text("Preview Card")
                .foregroundStyle(ColorTokens.textPrimary)
            Text("Testing both color schemes")
                .foregroundStyle(ColorTokens.textSecondary)
        }
    }
    .padding()
    .background(ColorTokens.backgroundPrimary)
    .environment(\.colorScheme, .dark)
}

Simulator Shortcut

In the iOS Simulator, toggle dark mode instantly with Cmd + Shift + A. No need to navigate to Settings. I keep the simulator in one mode while Xcode Previews show the other — this way I always see both simultaneously.

Automated Snapshot Tests

For production apps, pair your design tokens with snapshot tests using PointFree's swift-snapshot-testing library. Capture every key screen in both color schemes and store the snapshots in your repo. When someone changes a token value, the snapshot test fails, flagging the visual regression before it ships.

Seven Common Dark Mode Mistakes

I have reviewed dozens of indie iOS apps and the same mistakes come up repeatedly. Here is what to avoid:

  1. Hardcoding colors as raw hex values. Every color should come from your token system. If you see Color(hex: "1C1C1E") anywhere outside of ColorTokens.swift, that is a code smell.
  2. Using pure black (#000000) backgrounds. Pure black on OLED screens causes a "smearing" effect during scrolling because pixels take time to turn back on. Apple uses #000000 for the system background but #1C1C1E for elevated surfaces. Follow the same pattern.
  3. Ignoring system components. UINavigationBar, UITabBar, and UIAlertController have their own appearance APIs. If you theme your custom views but forget these, the app looks inconsistent. SwiftUI handles most of this automatically — but only if you are not overriding .background() on NavigationStack with a hardcoded color.
  4. Using the same shadow in both modes. As we covered, shadows are invisible on dark backgrounds. Either remove them or replace them with subtle borders or background elevation.
  5. Forgetting images and illustrations. If your app has illustrations with white backgrounds, they will look like glowing rectangles in dark mode. Either use transparent PNGs, provide dark variants in the Asset Catalog, or apply a renderingMode(.template) and tint them with a token color.
  6. Not testing with Increase Contrast. iOS has an "Increase Contrast" accessibility setting that many users enable. Your token system should respond to accessibilityContrast environment value and bump up contrast ratios. At minimum, test your app with this setting enabled.
  7. Breaking color semantics. Do not use your error token for decorative red elements. If something is red but not an error, create a separate decorativeRed token. Semantic naming prevents confusion and makes the token system self-documenting.

Extending the System: Custom Themes and Whitelabeling

Once you have a token-based system, adding custom themes becomes trivial. Want to offer a "Midnight Blue" theme or let enterprise clients whitelabel with their brand colors? Override specific tokens:

// Example: brand-specific theme override
extension AppTheme {
    static func brandTheme(primaryColor: Color) -> AppTheme {
        let theme = AppTheme()
        theme.accentOverride = primaryColor
        return theme
    }
}

// In your settings screen:
Button("Apply Brand Theme") {
    theme.accentOverride = Color(hex: "6C5CE7")
}

Because every component reads from the token system, changing one value propagates everywhere. No find-and-replace. No missed screens. This is the power of designing with tokens from day one.

Bringing It All Together

Let me recap the architecture we built:

  1. Color+Hex extension for creating colors from design spec hex values.
  2. ColorTokens struct with adaptive light/dark colors for every semantic use case.
  3. Spacing, Radius, and Shadow enums for consistent non-color design values.
  4. AppTheme observable injected via the environment for centralized access.
  5. Themed components (Button, Card) that consume tokens without any color scheme checks.
  6. Preview and testing patterns to verify both modes.

This is a solid foundation, but building a production design system still involves dozens of additional components — inputs, navigation bars, tab bars, modals, toasts, skeleton loaders — all of which need the same token treatment. If you want the complete, battle-tested version, check out The Swift Kit documentation. The kit ships with a full design token system, 30+ themed components, and both light and dark modes pre-configured and tested. Head over to the pricing page to see what is included, or explore the features overview to see the design system in action.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit