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.