SwiftUI's .primary and .secondary are elegant — adaptive, environment-aware, composable. But they're not yours. If your brand lives in a teal #1B8188, there's no built-in way to make it feel as native as .primary. You end up scattering Color(hex:) calls, handling dark mode manually, and losing the composability that makes SwiftUI's styling so nice. Let's fix that by building a theme system that plugs brand colors and gradients directly into .foregroundStyle() / .backgroundStyle() — the same way .primary does.
TL;DR — There's a ready-to-use package now
If you just want the solution — https://github.com/rozd/theme-kit is a production-ready Swift package that implements everything this article describes. Drop it in, declare your tokens in a JSON file, run one command, and you get a native-feeling theme system that works exactly like SwiftUI's built-in styles. Colors, gradients, shadows, dark mode, runtime switching, Codable themes — all handled. Works on iOS, macOS, watchOS, tvOS, and visionOS.
👉 https://github.com/rozd/theme-kit
The article below walks through the why and how behind the approach. The package came later as a complete implementation you can use today.
What We're Building
By the end, you'll be able to write this:
.foregroundStyle(.themePrimary)
It looks like SwiftUI's built-in .primary, but resolves to your brand color — and automatically adapts between light and dark mode. No @Environment boilerplate in the view, no colorScheme == .dark ? ... : ... ternaries.
The same pattern extends to gradients:
.fill(.themePrimaryGradient)
The same architecture supports mesh gradients too — though that's out of scope for this article, here's a taste of the API:
.fill(.themeBackgroundMeshGradient)
Same pattern, same environment integration, just a different style type under the hood.
And because the entire theme lives in the SwiftUI environment, switching themes at runtime is a single assignment:
theme = theme.copyWith(
colors: theme.colors.copyWith(
primary: .init(light: .purple, dark: .indigo)
)
)
Every view in the tree updates instantly. Let's build it.
The Building Blocks
The system is made of a small number of types, each with a single job:
-
ThemeAdaptiveStyle<T>— holds light/dark variants of any style -
ThemeColors— semantic color collection (primary, surface, etc.) -
ThemeGradients— semantic gradient collection -
Theme— root container for all style collections -
ThemeShapeStyle<T>— the bridge that makes it all feel native to SwiftUI
Plus some glue: environment integration, convenience extensions, and copyWith methods for runtime updates.
Let's build them one at a time.
Step 1: The Adaptive Style Wrapper
Every style in a theme needs two variants — one for light mode, one for dark. Rather than handling this per-type, we make a single generic wrapper:
nonisolated struct ThemeAdaptiveStyle<Style: Sendable & Codable>: Sendable, Codable {
let light: Style
let dark: Style
}
extension ThemeAdaptiveStyle {
nonisolated func resolved(for colorScheme: ColorScheme) -> Style {
colorScheme == .dark ? dark : light
}
}
This is the foundation everything else builds on. Style can be Color, Gradient, MeshGradient, or any custom type — the wrapper doesn't care. It just stores two variants and picks the right one based on the current color scheme.
Making it Codable and Sendable from the start pays off later when we want to load themes from Firestore or pass them across concurrency boundaries.
Step 2: Semantic Style Collections
Now we define what a "theme" actually contains. We use Material Design's naming convention — primary, onPrimary, surface, onSurface — because it maps well to real UI needs and most designers already think in these terms:
nonisolated struct ThemeColors: Codable {
let primary: ThemeAdaptiveStyle<Color>
let onPrimary: ThemeAdaptiveStyle<Color>
// ... secondary, surface, onSurface, etc.
}
Each field is a ThemeAdaptiveStyle<Color> — light and dark variants baked in. No optionals, no fallbacks. If you have a theme, it's complete.
Gradients follow the same pattern:
nonisolated struct ThemeGradients: Codable {
let primary: ThemeAdaptiveStyle<Gradient>
}
You add fields as your design system grows. The structure scales naturally.
Step 3: The Theme Container
The root Theme struct simply composes the collections:
nonisolated struct Theme: Sendable, Codable {
var colors: ThemeColors
var gradients: ThemeGradients
}
That's it. No protocol conformances to manage, no abstract base classes. Just data.
Step 4: Default Values
Every theme needs sensible defaults. We define them as static properties:
nonisolated extension ThemeColors {
static let `default` = ThemeColors(
primary : .init(light: Color(hex: 0x1B8188), dark: Color(hex: 0x1B8188)),
onPrimary : .init(light: Color(hex: 0xF7F5EC), dark: Color(hex: 0xF7F5EC)),
// ... surface, onSurface, etc.
)
}
nonisolated extension Theme {
static let `default` = Theme(
colors: .default,
gradients: .default,
)
}
Notice how primary keeps the same teal in both modes, but each slot gets independent light/dark control — a surface color, for example, might use a warm off-white in light mode and a deep slate in dark. The defaults are your starting point; everything is overridable.
Step 5: Environment Integration
To get the theme into SwiftUI views, we use the @Entry macro:
nonisolated extension EnvironmentValues {
@Entry var theme: Theme = .default
}
One line. The theme is now available everywhere via @Environment(\.theme), and defaults to your brand's theme with no setup required. Views that don't care about theming don't need to do anything.
At this point, you have a working theme system. You could stop here and use it like this:
struct BrandedLabel: View {
@Environment(\.theme) private var theme
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Text("Hello")
.foregroundStyle(theme.colors.primary.resolved(for: colorScheme))
}
}
It works, but it's verbose. Every view needs two @Environment properties and a .resolved(for:) call. We can do better.
Step 6: The Key Innovation — ThemeShapeStyle
This is where it gets interesting. SwiftUI's ShapeStyle protocol has a method called resolve(in:) that receives the full EnvironmentValues. We can use that to read both the theme and the color scheme, resolving the right variant automatically:
nonisolated struct ThemeShapeStyle<Style: ShapeStyle & Sendable & Codable>: ShapeStyle {
nonisolated(unsafe) let keyPath: KeyPath<Theme, ThemeAdaptiveStyle<Style>>
func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
environment.theme[keyPath: keyPath].resolved(for: environment.colorScheme)
}
}
This is small — nine lines — but it's the heart of the whole system. Let's break down what it does:
- It stores a
KeyPathfromThemeto anyThemeAdaptiveStyle<Style>(e.g.,\.colors.primaryor\.gradients.primary) - When SwiftUI resolves the style, it reads
environment.themeto get the current theme - It follows the key path to the specific adaptive style
- It calls
resolved(for:)with the current color scheme
The result: a ShapeStyle that is fully environment-aware — it reacts to both theme changes and color scheme changes, with zero boilerplate in the view.
The nonisolated(unsafe) on the key path is needed because KeyPath isn't Sendable in Swift 6's strict concurrency model, but our usage is safe since key paths are immutable value types.
Step 7: Convenience Extensions
ThemeShapeStyle is powerful but not ergonomic on its own — nobody wants to write .foregroundStyle(ThemeShapeStyle(keyPath: \.colors.primary)). We fix that with static properties on ShapeStyle:
extension ShapeStyle where Self == ThemeShapeStyle<Color> {
static var themePrimary : Self { .init(keyPath: \.colors.primary) }
static var themeOnPrimary : Self { .init(keyPath: \.colors.onPrimary) }
static var themeSurface : Self { .init(keyPath: \.colors.surface) }
// ... one per color slot
}
And for gradients:
extension ShapeStyle where Self == ThemeShapeStyle<Gradient> {
static var themePrimaryGradient: Self { .init(keyPath: \.gradients.primary) }
}
The theme prefix avoids collisions with SwiftUI's built-in styles. Yes, it looks like a bit of duplication — one static property per theme slot. But this is the kind of boilerplate that earns its keep: it gives you autocomplete, type safety, and a usage pattern that's identical to SwiftUI's own API.
Now our view is simply:
Text("Hello")
.foregroundStyle(.themePrimary)
No @Environment, no manual resolving, no conditionals. It reads like SwiftUI.
Runtime Theme Switching
Because the theme is just a value in the environment, switching it at runtime is trivial. In your root view (typically your App):
@main
struct MyApp: App {
@State private var theme: Theme = .default
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.theme, theme)
}
}
}
To update the theme, use the copyWith pattern — it lets you change specific fields without reconstructing the entire theme:
theme = theme.copyWith(
colors: theme.colors.copyWith(
primary: .init(
light: .purple,
dark: .indigo
)
)
)
copyWith is a simple method on each collection type:
nonisolated extension Theme {
func copyWith(
colors: ThemeColors? = nil,
gradients: ThemeGradients? = nil,
) -> Self {
.init(
colors: colors ?? self.colors,
gradients: gradients ?? self.gradients,
)
}
}
Each collection has its own copyWith that follows the same pattern — optional parameters that default to nil, meaning "keep the current value." It's a lightweight alternative to a builder pattern, and it reads naturally.
Bonus: Firestore-Powered Remote Theming
Because everything in the system is Codable, you can stream themes from a database. The Theme, ThemeColors, ThemeGradients, and ThemeAdaptiveStyle structs are all Codable by default. But Color and Gradient aren't — we need to teach Swift how to encode them.
For Color, we use a hex string representation:
nonisolated extension Color: @retroactive Codable {
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let hex = try container.decode(String.self)
self = Color(hex: hex)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(self.hexString ?? "#000000")
}
}
For Gradient, we encode just the colors (as an array of hex strings):
nonisolated extension Gradient: @retroactive Codable {
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let colors = try container.decode([Color].self)
self = .init(colors: colors)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(self.stops.map { $0.color })
}
}
The @retroactive attribute tells Swift we're intentionally extending a type we don't own with a protocol conformance we don't own — it silences the warning that would normally fire.
With these conformances in place, streaming a theme from Firestore is straightforward:
@main
struct MyApp: App {
@State private var theme: Theme = .default
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.theme, theme)
.task {
do {
for try await snapshot in Firestore.firestore()
.document("config/theme")
.stream
{
theme = try snapshot.data(as: Theme.self)
}
} catch {
print("Theme stream error: \(error)")
}
}
}
}
}
The .stream property produces an AsyncThrowingStream of Firestore snapshots. Every time the config/theme document changes, the theme updates and every view in the app re-renders with the new styles. Your designer can tweak brand colors in the Firebase console and see them reflected on every user's device in real time — no app update required.
The app starts with .default immediately, so there's no loading state. The Firestore stream just refines it when data arrives.
Beyond Colors and Gradients
For types that don’t fit the ShapeStyle model — like sizes, corner radii, or spacing — you can add them directly to Theme and access them through the environment:
@Environment(\.theme) private var theme
// ...
.frame(height: theme.sizes.cardHeight)
However, styles that need to be converted into ShapeStyle require additional types. I will try to cover those in a future article.
Recap
Here's the full architecture in one diagram:
Theme (root)
├── colors: ThemeColors
│ ├── primary: ThemeAdaptiveStyle<Color> ─┐
│ ├── onPrimary: ThemeAdaptiveStyle<Color> │
│ └── ... │
├── gradients: ThemeGradients │
│ └── primary: ThemeAdaptiveStyle<Gradient> │
└── ... │
│
ThemeShapeStyle<Color> │
keyPath: \.colors.primary ──────────────────┘
resolve(in:) → reads theme + colorScheme → returns Color
Usage:
.foregroundStyle(.themePrimary) // ← no @Environment, no boilerplate
The entire system is about 150 lines of code across a handful of files. No third-party dependencies, no runtime overhead beyond a key path lookup, and full Codable support for remote theming.
The key insight is that SwiftUI's ShapeStyle.resolve(in:) gives you access to the full environment — and that's all you need to build a theme system that feels like it was always part of the framework.
The code in this article is extracted from FitnessArt, a fitness studio management app built with SwiftUI and Firebase.
Top comments (0)