
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:
| Product | Use when |
|---|---|
ThemeKit | Core types only — building a custom UI layer |
ThemeKitSwiftUI | SwiftUI apps |
ThemeKitUIKit | UIKit apps (iOS only) |
- Requirements
- Installation
- Getting Started
- Core Concepts
- User-customizable fields
- SwiftUI
- UIKit
- API Reference
- Running the tests
- AI coding agents
- iOS 17+ or macOS 14+
- Swift 6
In Xcode: File → Add Package Dependencies, enter the repository URL, then add the product that matches your target (ThemeKitSwiftUI or ThemeKitUIKit).
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.
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.
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),]}}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.
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),]}}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]}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)}}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).
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 unchangedFull 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 presetReturns 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
}// 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)})}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)}}}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)}}}// 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 =trueUIKit support is iOS-only (ThemeKitUIKit does not compile on macOS).
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()}}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()}}}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)| Method / Property | Description |
|---|---|
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 |
followsSystem | Whether the theme mirrors the system light/dark appearance |
activeVariantID | ID of the last applied variant |
Full reference for all public types and methods across ThemeKit, ThemeKitSwiftUI, and ThemeKitUIKit: REFERENCE.md
Online documentation (DocC): demolaf.github.io/ThemeKit
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 arm64To 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 ThemeKitSwiftUITestsAvailable test targets: ThemeKitTests, ThemeKitSwiftUITests, ThemeKitUIKitTests.
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.