DEV Community

Max Rozdobudko
Max Rozdobudko

Posted on • Edited on

Building a Native-Feeling Theme System in SwiftUI

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
    )
)
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
    )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

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
}
Enter fullscreen mode Exit fullscreen mode

And for gradients:

extension ShapeStyle where Self == ThemeShapeStyle<Gradient> {
    static var themePrimaryGradient: Self { .init(keyPath: \.gradients.primary) }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        )
    )
)
Enter fullscreen mode Exit fullscreen mode

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,
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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 })
    }
}
Enter fullscreen mode Exit fullscreen mode

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)")
                    }
                }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)