min read

SwiftUI Theming Patterns That Scale

A practical, type-safe theming system for SwiftUI with multiple palettes, appearance modes, and environment-driven colors.

SwiftUI Theming Patterns That Scale

SwiftUI Design System Themes

When building Systemic, I needed more than light/dark mode. I wanted multiple palettes, a consistent semantic color layer, and a clean way to inject colors across the app.

The Approach

Systemic uses:

  • ColorTheme for named palettes (Ember, Ocean, Forest, Aurora, Sand)
  • AppearanceMode for system/light/dark selection
  • ThemeProvider as the single source of truth
  • Resolved colors injected via environment so views only read themeColors

1) Define Palettes

enum ColorTheme: String, CaseIterable, Identifiable {
    case ember = "Ember"
    case ocean = "Ocean"
    case forest = "Forest"
    case aurora = "Aurora"
    case sand = "Sand"

    var id: String { rawValue }
    var displayName: String { rawValue }
}

2) Centralize Theme State

@Observable
final class ThemeProvider {
    static let shared = ThemeProvider()

    var colorTheme: ColorTheme {
        didSet { UserDefaults.standard.set(colorTheme.rawValue, forKey: "selectedColorTheme") }
    }

    var appearanceMode: AppearanceMode {
        didSet { UserDefaults.standard.set(appearanceMode.rawValue, forKey: "selectedAppearanceMode") }
    }

    init() {
        if let savedTheme = UserDefaults.standard.string(forKey: "selectedColorTheme"),
           let theme = ColorTheme(rawValue: savedTheme) {
            self.colorTheme = theme
        } else {
            self.colorTheme = .ember
        }

        if let savedMode = UserDefaults.standard.string(forKey: "selectedAppearanceMode"),
           let mode = AppearanceMode(rawValue: savedMode) {
            self.appearanceMode = mode
        } else {
            self.appearanceMode = .system
        }
    }
}

3) Resolve Semantic Colors

Rather than passing ColorScheme everywhere, the app resolves a ThemeColors struct once and injects it:

struct ThemeColors {
    let accent: Color
    let background: Color
    let textPrimary: Color
    let border: Color
}

extension View {
    func resolveThemeColors() -> some View {
        self.modifier(ResolveThemeColorsModifier())
    }
}

Views then read colors directly:

@Environment(\.themeColors) private var colors

Text("Today")
    .foregroundStyle(colors.textPrimary)
    .padding()
    .background(colors.background)

4) Appearance Mode (System/Light/Dark)

enum AppearanceMode: String, CaseIterable, Identifiable {
    case system = "System"
    case light = "Light"
    case dark = "Dark"

    var colorScheme: ColorScheme? {
        switch self {
        case .system: return nil
        case .light: return .light
        case .dark: return .dark
        }
    }
}

Tradeoffs

  • Slightly more boilerplate than Asset Catalog colors
  • You must keep the semantic color layer consistent

Conclusion

This pattern keeps Systemic’s UI consistent across multiple palettes without littering views with color logic. The end result is a scalable design system that’s easy to extend as the app grows.

J
Written by

Jack O'Shea

DevOps Engineer and Software Developer building reliable systems with clean code.