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](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](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:
```swift
.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:
```swift
.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:
```swift
.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:
```swift
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:
```swift
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:
```swift
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:
```swift
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:
```swift
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:
```swift
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:
```swift
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:
```swift
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:
```swift
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:
1. It stores a `KeyPath` from `Theme` to any `ThemeAdaptiveStyle<Style>` (e.g., `\.colors.primary` or `\.gradients.primary`)
2. When SwiftUI resolves the style, it reads `environment.theme` to get the current theme
3. It follows the key path to the specific adaptive style
4. 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`:
```swift
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:
```swift
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:
```swift
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`):
```swift
@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:
```swift
theme = theme.copyWith(
colors: theme.colors.copyWith(
primary: .init(
light: .purple,
dark: .indigo
)
)
)
```
`copyWith` is a simple method on each collection type:
```swift
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:
```swift
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):
```swift
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:
```swift
@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:
```swift
@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.*