Skip to content

Repository files navigation

demo-example-app

ThemeKit

SwiftPlatformSPMLicenseStarsForks

A Swift package for managing light/dark theme variants in iOS and macOS apps. Handles first-launch defaults, system appearance sync, custom color overrides, and persistence — so your app code only has to describe what the theme looks like, not how it behaves.

Three library products:

ProductUse when
ThemeKitCore types only — building a custom UI layer
ThemeKitSwiftUISwiftUI apps
ThemeKitUIKitUIKit apps (iOS only)

Table of Contents


Requirements

  • iOS 17+ or macOS 14+
  • Swift 6

Installation

In Xcode: File → Add Package Dependencies, enter the repository URL, then add the product that matches your target (ThemeKitSwiftUI or ThemeKitUIKit).


Getting Started

The minimal path to a working theme in a SwiftUI app is four steps.

1. Define your theme type

import ThemeKit
import ThemeKitSwiftUI
structAppColors:ThemeExtension{staticletfallback=AppColors(tint:Color(hex:0x007AFF), colorScheme:.light)vartint:ColorvarcolorScheme:SystemColorScheme}

2. Define your presets

structAppColorsVariant:ThemeVariant{letid:Stringletlight:AppColorsletdark:AppColorsstaticlet`default`=AppColorsVariant(
id:"default",
light:AppColors(tint:Color(hex:0x007AFF), colorScheme:.light),
dark:AppColors(tint:Color(hex:0x0A84FF), colorScheme:.dark))staticletall:[AppColorsVariant]=[.default]}

3. Add a typed accessor

extensionTheme{varcolors:AppColors{value(AppColors.self)}}

4. Wire up and read

@mainstructMyApp:App{@Stateprivatevartheme=Theme()varbody:someScene{WindowGroup{ContentView().environment(theme).applyTheme(theme, default:.default, available:AppColorsVariant.all)}}}structContentView:View{@Environment(Theme.self)privatevarthemevarbody:someView{Text("Hello").foregroundStyle(theme.colors.tint)}}

That's it. ThemeKit handles first-launch defaults, system appearance sync, and persistence automatically. Read on for the full API.


Core Concepts

1. Define your theme — ThemeExtension

ThemeExtension is the type that holds your app's theme values. It must be Codable, Equatable, and Sendable. It can carry any codable value — colors, font names, image asset names, spacing constants, or anything else your design system needs.

Colors (SwiftUI)

ThemeKitSwiftUI makes Color directly Codable, so you can store it without any conversion at the call site.

import ThemeKit
import ThemeKitSwiftUI
structAppColors:ThemeExtension,ThemeOverridable{staticletfallback=AppColors(
tint:Color(hex:0x8E44AD),
background:Color(hex:0xFFFFFF),
colorScheme:.light
)vartint:Colorvarbackground:ColorvarcolorScheme:SystemColorScheme // required by the protocol
// Declare which fields the user can individually override.
// theme.merge(_:) copies only these fields from the incoming value;
// compare(to:) uses them to detect whether any differ from a preset.
varprops:[Prop<Self>]{[.init(\.tint),]}}

Colors (UIKit)

Use the @CodableColor property wrapper for UIColor properties. The call site reads theme.colors.tint and gets a UIColor directly — no conversion needed.

import ThemeKit
structAppColors:ThemeExtension,ThemeOverridable{staticletfallback=AppColors(
tint:UIColor(hex:0x8E44AD),
background:UIColor(hex:0xFFFFFF),
colorScheme:.light
)@CodableColorvartint:UIColor@CodableColorvarbackground:UIColorvarcolorScheme:SystemColorSchemevarprops:[Prop<Self>]{[.init(\.tint),]}}

Both Color and @CodableColor encode to the same hex integer format, so storage written by one target can be read by the other.

Fonts, images, and icons

ThemeExtension isn't limited to colors. Store font names and image asset names as String, then add computed properties to derive the richer types your views consume.

import ThemeKit
import ThemeKitSwiftUI
structAppTheme:ThemeExtension,ThemeOverridable{staticletfallback=AppTheme(
accent:Color(hex:0xCC0000),
backgroundImageName:"bg-light",
iconImageName:"icon-default",
fontName:"Georgia",
colorScheme:.light
)varaccent:ColorvarbackgroundImageName:String // asset catalog image name
variconImageName:String // asset catalog image name
varfontName:String // empty string = system font
varcolorScheme:SystemColorScheme
// Computed — not stored, so no Codable involvement
vartitleFont:Font{
fontName.isEmpty
?.largeTitle.weight(.bold):.custom(fontName, size:34, relativeTo:.largeTitle)}varbodyFont:Font{
fontName.isEmpty
?.body
:.custom(fontName, size:17, relativeTo:.body)}varprops:[Prop<Self>]{[.init(\.accent),.init(\.backgroundImageName),.init(\.iconImageName),]}}

2. Define your presets — ThemeVariant

ThemeVariant pairs a light and dark ThemeExtension value under a stable string ID.

structAppThemeVariant:ThemeVariant{letid:Stringletname:String // not a ThemeVariant requirement — add any extra fields you need
letlight:AppThemeletdark:AppThemestaticletclassic=AppThemeVariant(
id:"classic",
name:"Classic",
light:AppTheme(accent:Color(hex:0xCC0000), backgroundImageName:"bg-classic-light", iconImageName:"icon-classic", fontName:"Georgia", colorScheme:.light),
dark:AppTheme(accent:Color(hex:0xFF6B6B), backgroundImageName:"bg-classic-dark", iconImageName:"icon-classic", fontName:"Georgia", colorScheme:.dark))staticletminimal=AppThemeVariant(
id:"minimal",
name:"Minimal",
light:AppTheme(accent:Color(hex:0x1A5276), backgroundImageName:"bg-minimal-light", iconImageName:"icon-minimal", fontName:"", colorScheme:.light),
dark:AppTheme(accent:Color(hex:0x7FD4F4), backgroundImageName:"bg-minimal-dark", iconImageName:"icon-minimal", fontName:"", colorScheme:.dark))staticletall:[AppThemeVariant]=[.classic,.minimal]}

3. Add convenience accessors — Theme extensions

Each ThemeExtension type needs one accessor. Multiple types coexist in a single Theme instance under separate keys:

extensionTheme{varappColors:AppColors{value(AppColors.self)}varappTheme:AppTheme{value(AppTheme.self)}}

User-customizable fields — ThemeOverridable

ThemeOverridable is an independent protocol types adopt alongside ThemeExtension when some fields should be individually overridable by the user (e.g. an accent color set via a color picker) while other fields remain controlled by the active preset.

import ThemeKit
import ThemeKitSwiftUI
structAppColors:ThemeExtension,ThemeOverridable{staticletfallback=AppColors(tint:Color(hex:0x8E44AD), background:Color(hex:0xFFFFFF), colorScheme:.light)vartint:Colorvarbackground:ColorvarcolorScheme:SystemColorSchemevarprops:[Prop<Self>]{[.init(\.tint), // tint is user-customisable; background always comes from the preset
]}}

props drives two operations: merge (which fields to copy in) and compare(to:) (which fields to check for drift from a preset).

theme.merge(_ value:)

Overlays only the props fields from value onto the currently stored value. Non-listed fields stay from the stored base. Use this when the user changes a field via a color picker — it keeps all other preset fields in place.

varcustom= theme.colors
custom.tint = newColor // tint is in props
theme.merge(custom) // stored value: base preset + custom tint; background unchanged

theme.apply(variant:for:)

Full replacement — all fields come from the preset. props fields are not preserved. Use this when the user selects a preset.

theme.apply(variant:.ocean, for:.light)
// All fields, including tint, now come from the ocean preset

compare(to:)

Returns true if any props field on self differs from the same field on preset. Use this to decide whether to show a "Reset to Preset" button.

letactiveVariant=AppColorsVariant.all.first{ $0.id == theme.activeVariantID }??.default
letpreset= activeVariant.value(for: theme.colors.colorScheme)if theme.colors.compare(to: preset){
// tint has been customised — show the Reset button
}

Full picker example

// SwiftUI
Section("Custom"){ColorPicker("Tint", selection: tintBinding)letactiveVariant=AppColorsVariant.all.first{ $0.id == theme.activeVariantID }??.default
letpreset= activeVariant.value(for: theme.colors.colorScheme)if theme.colors.compare(to: preset){Button("Reset to Preset", role:.destructive){
theme.apply(variant: activeVariant, for: theme.colors.colorScheme)}}}privatevartintBinding:Binding<Color>{Binding(
get:{ theme.colors.tint },
set:{ newColor invarcustom= theme.colors
custom.tint = newColor
theme.merge(custom)})}

SwiftUI

Setup

Attach .applyTheme at the root of your view hierarchy. Pass a default variant and the full list of available variants.

import ThemeKit
import ThemeKitSwiftUI
@mainstructMyApp:App{@Stateprivatevartheme=Theme()varbody:someScene{WindowGroup{ContentView().environment(theme).applyTheme(theme, default:.classic, available:AppThemeVariant.all)}}}

Reading theme values

Read colors, fonts, and images through the typed accessor on Theme.

structContentView:View{@Environment(Theme.self)privatevarthemevarbody:someView{VStack{
// Background image from the asset catalog
Image(theme.appTheme.backgroundImageName).resizable().scaledToFill().ignoresSafeArea()
// Icon from the asset catalog
Image(theme.appTheme.iconImageName).resizable().scaledToFit().frame(width:80, height:80)
// Themed font and color
Text("Hello").font(theme.appTheme.titleFont).foregroundStyle(theme.appTheme.accent)Text("Subtitle").font(theme.appTheme.bodyFont)}}}

Writing theme values

// Select a preset — records the variant ID and sets followsSystem to false
theme.apply(variant:AppThemeVariant.classic, for:.dark)
// Apply a custom accent color — only the fields in overrideProps are overlaid;
// other fields (backgroundImageName, iconImageName) stay from the stored value.
// Also sets followsSystem to false.
varcustom= theme.appTheme
custom.accent =Color(hex:0xFF0000)
theme.merge(custom)
// Follow system light/dark
theme.followsSystem =true

UIKit

UIKit support is iOS-only (ThemeKitUIKit does not compile on macOS).

Setup

Create a ThemeApplier in your SceneDelegate and wire up its three lifecycle hooks.

import ThemeKit
import ThemeKitUIKit
classSceneDelegate:UIResponder,UIWindowSceneDelegate{varwindow:UIWindow?lettheme=Theme()privatevarthemeApplier:ThemeApplier<AppThemeVariant>?func scene(_ scene:UIScene, willConnectTo session:UISceneSession, options:UIScene.ConnectionOptions){guardlet windowScene = scene as?UIWindowSceneelse{return}letwindow=UIWindow(windowScene: windowScene)
window.rootViewController =ViewController(theme: theme)self.window = window
window.makeKeyAndVisible()letapplier=ThemeApplier(theme: theme, default:.classic, available:AppThemeVariant.all, window: window)
themeApplier = applier
applier.onAppear()
applier.onChangeOfThemeState()
applier.onChangeOfSystemUserInterfaceStyle()}}

Reading theme values

Observe theme with withObservationTracking and apply values to your views directly.

privatefunc observeTheme(){withObservationTracking{
// Colors via @CodableColor — already UIColor, no conversion
view.backgroundColor = theme.appColors.background
view.tintColor = theme.appColors.tint
// Image asset name
heroImageView.image =UIImage(named: theme.appTheme.backgroundImageName)
// Font name stored as String, converted at the call site
titleLabel.font =UIFont(name: theme.appTheme.fontName, size:34)??.preferredFont(forTextStyle:.largeTitle)} onChange:{[weak self]inTask{@MainActor[weak self]inself?.observeTheme()}}}

Writing theme values

The API is the same as SwiftUI — Theme is framework-agnostic.

// Select a preset — records the variant ID and sets followsSystem to false
theme.apply(variant:AppThemeVariant.classic, for:.dark)
// Apply a custom accent color via merge
varcustom= theme.appTheme
custom.accent =UIColor(hex:0xFF0000)
theme.merge(custom)

Theme API reference

Method / PropertyDescription
value(_ type:)Read the current stored value for an extension type
apply(_ value:)Replace the stored value entirely
merge(_ value:)Overlay the overrideProps fields from value onto the stored value; sets followsSystem to false
apply(variant:for:)Apply a variant's light or dark value, record its ID, and set followsSystem to false
hasPersisted(_ type:)Returns true if a value has ever been stored for this type
followsSystemWhether the theme mirrors the system light/dark appearance
activeVariantIDID of the last applied variant

API Reference

Full reference for all public types and methods across ThemeKit, ThemeKitSwiftUI, and ThemeKitUIKit: REFERENCE.md

Online documentation (DocC): demolaf.github.io/ThemeKit


Running the tests

Run against the iOS Simulator via xcodebuild:

xcodebuild test \
-workspace .swiftpm/xcode/package.xcworkspace \
-scheme ThemeKit-Package \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro'

Run natively on macOS via swift test (exercises ThemeKit and ThemeKitSwiftUI; ThemeKitUIKitTests are skipped since UIKit is unavailable):

swift test --arch arm64

To filter to a single test target, use -only-testing:

xcodebuild test \
-workspace .swiftpm/xcode/package.xcworkspace \
-scheme ThemeKit-Package \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-only-testing ThemeKitSwiftUITests

Available test targets: ThemeKitTests, ThemeKitSwiftUITests, ThemeKitUIKitTests.


AI coding agents

This repo ships a SKILL.md at .agents/skills/themekit/, following the cross-tool Agent Skills open standard. Agents that scan .agents/skills/ (Codex, and other agentskills.io-compatible tools) pick it up automatically when working in a project that depends on ThemeKit; Claude Code users can copy or symlink it into their own project's .claude/skills/ to get the same effect.

About

A Swift package for managing light/dark theme variants in iOS and macOS apps.

Topics

Resources

Stars

16 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages