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
- 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
struct AppColors: ThemeExtension {
static let fallback = AppColors(tint: Color(hex: 0x007AFF), colorScheme: .light)
var tint: Color
var colorScheme: SystemColorScheme
}2. Define your presets
struct AppColorsVariant: ThemeVariant {
let id: String
let light: AppColors
let dark: AppColors
static let `default` = AppColorsVariant(
id: "default",
light: AppColors(tint: Color(hex: 0x007AFF), colorScheme: .light),
dark: AppColors(tint: Color(hex: 0x0A84FF), colorScheme: .dark)
)
static let all: [AppColorsVariant] = [.default]
}3. Add a typed accessor
extension Theme {
var colors: AppColors { value(AppColors.self) }
}4. Wire up and read
@main
struct MyApp: App {
@State private var theme = Theme()
var body: some Scene {
WindowGroup {
ContentView()
.environment(theme)
.applyTheme(theme, default: .default, available: AppColorsVariant.all)
}
}
}
struct ContentView: View {
@Environment(Theme.self) private var theme
var body: some View {
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
struct AppColors: ThemeExtension, ThemeOverridable {
static let fallback = AppColors(
tint: Color(hex: 0x8E44AD),
background: Color(hex: 0xFFFFFF),
colorScheme: .light
)
var tint: Color
var background: Color
var colorScheme: 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.
var props: [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
struct AppColors: ThemeExtension, ThemeOverridable {
static let fallback = AppColors(
tint: UIColor(hex: 0x8E44AD),
background: UIColor(hex: 0xFFFFFF),
colorScheme: .light
)
@CodableColor var tint: UIColor
@CodableColor var background: UIColor
var colorScheme: SystemColorScheme
var props: [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
struct AppTheme: ThemeExtension, ThemeOverridable {
static let fallback = AppTheme(
accent: Color(hex: 0xCC0000),
backgroundImageName: "bg-light",
iconImageName: "icon-default",
fontName: "Georgia",
colorScheme: .light
)
var accent: Color
var backgroundImageName: String // asset catalog image name
var iconImageName: String // asset catalog image name
var fontName: String // empty string = system font
var colorScheme: SystemColorScheme
// Computed — not stored, so no Codable involvement
var titleFont: Font {
fontName.isEmpty
? .largeTitle.weight(.bold)
: .custom(fontName, size: 34, relativeTo: .largeTitle)
}
var bodyFont: Font {
fontName.isEmpty
? .body
: .custom(fontName, size: 17, relativeTo: .body)
}
var props: [Prop<Self>] {[
.init(\.accent),
.init(\.backgroundImageName),
.init(\.iconImageName),
]}
}ThemeVariant pairs a light and dark ThemeExtension value under a stable string ID.
struct AppThemeVariant: ThemeVariant {
let id: String
let name: String // not a ThemeVariant requirement — add any extra fields you need
let light: AppTheme
let dark: AppTheme
static let classic = 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)
)
static let minimal = 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)
)
static let all: [AppThemeVariant] = [.classic, .minimal]
}Each ThemeExtension type needs one accessor. Multiple types coexist in a single Theme instance under separate keys:
extension Theme {
var appColors: AppColors { value(AppColors.self) }
var appTheme: 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.
struct AppColors: ThemeExtension, ThemeOverridable {
static let fallback = AppColors(...)
var tint: Color
var background: Color
var colorScheme: SystemColorScheme
var props: [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.
var custom = 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.
let activeVariant = AppColorsVariant.all.first { $0.id == theme.activeVariantID } ?? .default
let preset = 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)
let activeVariant = AppColorsVariant.all.first { $0.id == theme.activeVariantID } ?? .default
let preset = 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)
}
}
}
private var tintBinding: Binding<Color> {
Binding(
get: { theme.colors.tint },
set: { newColor in
var custom = 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
@main
struct MyApp: App {
@State private var theme = Theme()
var body: some Scene {
WindowGroup {
ContentView()
.environment(theme)
.applyTheme(theme, default: .classic, available: AppThemeVariant.all)
}
}
}Read colors, fonts, and images through the typed accessor on Theme.
struct ContentView: View {
@Environment(Theme.self) private var theme
var body: some View {
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.
var custom = 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
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
let theme = Theme()
private var themeApplier: ThemeApplier<AppThemeVariant>?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = ViewController(theme: theme)
self.window = window
window.makeKeyAndVisible()
let applier = ThemeApplier(theme: theme, default: .classic, available: AppThemeVariant.all)
themeApplier = applier
applier.onAppear()
applier.onChangeOfThemeState()
applier.onChangeOfSystemUserInterfaceStyle(window: window)
}
}Observe theme with withObservationTracking and apply values to your views directly.
private func 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] in
Task { @MainActor [weak self] in self?.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
var custom = 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
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.