Building a Native-Feeling Theme System in SwiftUI — DeepSeek Blog | Neura Market
    Neura MarketNeura Market/DeepSeek
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityDeepSeekDeepSeek
    CoPilotCoPilotStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsBlogVideosGuidesCoursesCommunityTrendingGenerate
    DeepSeekBlogBuilding a Native-Feeling Theme System in SwiftUI
    Back to Blog
    Building a Native-Feeling Theme System in SwiftUI
    ios

    Building a Native-Feeling Theme System in SwiftUI

    Max Rozdobudko February 16, 2026
    0 views

    SwiftUI's .primary and .secondary are elegant — adaptive, environment-aware, composable. But they're...

    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.*

    Tags

    iosswiftswiftuithemes

    Comments

    More Blog

    View all
    How I'm using ASTs and Gemini to solve the "Codebase Onboarding" problem 🧠ai

    How I'm using ASTs and Gemini to solve the "Codebase Onboarding" problem 🧠

    Hi everyone! 👋 I’m Tara, a Senior Software Engineer and Consultant. Over the years, I've jumped...

    T
    tworrell
    Local AI Will Save Us All (The Math Says So, Trust Me)ai

    Local AI Will Save Us All (The Math Says So, Trust Me)

    Every few weeks a take goes viral in tech circles making the case for ditching cloud AI and running...

    S
    Sebastian Schürmann
    Lost in the AI Hype, I Started Smallai

    Lost in the AI Hype, I Started Small

    And it helped me get back into tech without drowning TL;DR at the end Coming back to...

    R
    Rohini Gaonkar
    Building a Replay-Tested Interactive Brokers Client in Gogo

    Building a Replay-Tested Interactive Brokers Client in Go

    I wanted an IBKR library that felt like Go and had testing I could trust. So I wrote one.

    T
    Thomas Marcelis
    Playwright in Pictures: Fully Parallel Modeplaywright

    Playwright in Pictures: Fully Parallel Mode

    Playwright’s fullyParallel mode is often treated as a simple performance switch. In practice, it...

    V
    Vitaliy Potapov
    Designing a CLI for Both Humans and Agentscli

    Designing a CLI for Both Humans and Agents

    Learn how Alpic designed its CLI for both human developers and AI agents — covering tradeoffs like polling, context windows, interactivity, and statelessness.

    J
    Julien Vallini

    Stay up to date

    Get the latest DeepSeek prompts, rules, and resources delivered to your inbox weekly.

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for DeepSeek and more.

    Content Types

    • Rules
    • Prompts
    • MCPs
    • Agents
    • Guides

    Platforms

    • ChatGPT Directory
    • Claude Directory
    • Gemini Directory
    • Cursor Directory
    • Grok Directory
    • Perplexity Directory
    • DeepSeek Directory
    • CoPilot Directory
    • Stable Diffusion Directory
    • Midjourney Directory
    • All Directories

    Resources

    • Blog
    • Documentation
    • Help Center
    • Marketplace

    Legal

    • Privacy Policy
    • Terms of Service

    © 2026 Neura Market. All rights reserved.

    |

    Not affiliated with any AI platform vendors.