Building a Native-Feeling Theme System in SwiftUI —…
    Neura MarketNeura Market/DeepSeek
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityDeepSeekDeepSeek
    CoPilotCoPilotStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsGamesBlogVideosGuidesCoursesCommunityTrending
    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 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:

    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
    }
    

    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.

    Tags

    iosswiftswiftuithemes

    Comments

    More Blog

    View all
    Five Gemma-4 models, one accelerator: what porting E2B 31B to AWS Inferentia2 taught megemma

    Five Gemma-4 models, one accelerator: what porting E2B 31B to AWS Inferentia2 taught me

    I ported the whole Gemma-4 family — E2B, E4B, 12B, 31B, and the 26B-A4B MoE — to run on...

    X
    xbill
    Hey DEV, I'm Tobore. Let's actually connect.community

    Hey DEV, I'm Tobore. Let's actually connect.

    Hey DEV, I'm Tobore. Let's actually connect. I've been on here for a while now, mostly writing and...

    L
    Laurina Ayarah
    I burned through thousands of AI tokens. Then a friend did it for freeai

    I burned through thousands of AI tokens. Then a friend did it for free

    (yep, kinda clickbait, just for the funsies 😊) At the beginning of the year, I relaunched my...

    P
    Paulo Henrique
    Claude might be saturating your machineai

    Claude might be saturating your machine

    My laptop was sitting idle with the fan at full tilt. Nothing was running that I knew of. The culprit...

    S
    Sidhant Panda
    Automated GitHub Code Reviews Using Google Geminigithubactions

    Automated GitHub Code Reviews Using Google Gemini

    I Built a Thing! TL;DR — Google Gemini-based Pull Request reviews and Issue Triaging for...

    D
    Darren "Dazbo" Lester
    What is an "agentic harness," actually?ai

    What is an "agentic harness," actually?

    I've been hearing the word "harness" thrown around a lot lately. I assumed it just meant "the IDE" or...

    T
    Tilde A. Thurium

    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.

    Neura Market

    Custom AI Systems & Services

    Our team of experienced AI builders will help build custom AI systems, workflows, and solutions for your business.

    Request custom work

    Ready-made automations for this

    Workflows from the Neura Market marketplace related to this DeepSeek resource

    • Building a RAG Chatbot for Movie Recommendations with Qdrant and OpenAIn8n · $14.99 · Related topic
    • Readable Workflow Export & Deployment Pipeline for Multi-Environment CI/CDn8n · $14.99 · Related topic
    • Automated Reservation System with Telegram, Google Gemini AI, and Google Sheetsn8n · $9.99 · Related topic
    • Automate MERN Stack Environment Setup on Linuxn8n · $14.99 · Related topic
    Browse all workflows