Native-feeling theming for SwiftUI, powered by the environment.
ThemeKit gives your app a design token system that works exactly like SwiftUI's built-in styles. Every token is adaptive — variants resolve automatically based on the environment (color scheme, size class, or any custom axis). You declare your tokens in a simple JSON file (just names, no colors), run one command, and you're ready to use .foregroundStyle(.surface) anywhere.
- 🍎 SwiftUI Native — tokens resolve through
ShapeStyle.resolve(in:), the same mechanism as.primaryand.tint. No@Environmentwrappers needed in views. - 🎭 Adaptive by Default — tokens adapt by color scheme (light/dark), size class (compact/regular), or any custom axis via resolvers. The correct variant resolves automatically at render time.
- 🎈 Minimal Core — the library is just
ThemeAdaptiveStyleand a fewCodableextensions. Everything else is generated. - 🪄 Easy Setup — declare tokens in JSON, run the plugin once, fill in your colors, done. Zero imports required in your app code.
- 📖 Transparent Logic — the thin core and generated files are easy to read. Each file has a clear, specific role that is obvious at a glance.
- 🎛️ Full Control — generated files live in your project, fully readable and yours to extend.
theme-kit-demo-zoom-720.mp4
You don't need to write JSON by hand, use the ThemeKit Configurator — a visual editor that lets you toggle categories, add tokens, configure style overrides, and copy the finished theme.json straight into your project.
In Xcode: File → Add Package Dependencies, then enter:
https://github.com/rozd/theme-kit
Add a theme.json to your project root (or use the Configurator to build it visually). This file is just a list of token names — no colors, no gradients, no design values. You're declaring the shape of your design language; actual values come in step 4.
Only include the categories you need — the generated Theme struct will match:
{
"$schema": "https://raw.githubusercontent.com/rozd/theme-kit/main/theme.schema.json",
"styles": {
"colors": [
"surface",
"onSurface",
{ "name": "primary", "style": "primaryColor" }
],
"gradients": [
{ "name": "primary", "style": "primaryGradient" }
]
},
"config": {
"outputPath": ".",
"shouldGeneratePreview": true
}
}Use the object form
{ "name": ..., "style": ... }when a token name conflicts with a SwiftUI built-in (e.g.primary→primaryColor).Set
"shouldGeneratePreview": truein the config section to generate aTheme+Preview.swiftfile with a SwiftUI view that displays all your tokens as a visual palette.
Right-click your project in the Xcode navigator → Generate Theme Files.
What gets generated?
- Token structs (
ThemeColors,ThemeGradients, etc.) - A root
Themecontainer with only the categories you declared ShapeStyleextensions so tokens work as.surface,.primaryColor, etc.ThemeShadowedStylefor chaining shadow tokens onto any style (only when shadows are configured)Environment+Theme.swiftfor environment plumbingcopyWithhelpers for immutable updates- A
Theme+Defaults.swiftscaffold for you to fill in - A
Theme+Preview.swiftfile with a visual preview of all tokens (only whenshouldGeneratePreviewis enabled)
Open the generated Theme+Defaults.swift and replace the placeholders with your design values:
import SwiftUI
import ThemeKit
nonisolated extension Theme {
static let `default` = Theme(
colors: .default,
gradients: .default
)
}
// MARK: - ThemeColors
nonisolated extension ThemeColors {
static let `default` = ThemeColors(
surface: .init(light: Color(hex: 0xF7F5EC), dark: Color(hex: 0x1A1A1A)),
onSurface: .init(light: Color(hex: 0x2D2D2D), dark: Color(hex: 0xF0F0F0)),
primary: .init(light: Color(hex: 0x1B8188), dark: Color(hex: 0x3DBCC4))
)
}
// MARK: - ThemeGradients
nonisolated extension ThemeGradients {
static let `default` = ThemeGradients(
primary: .init(
light: .init(colors: [Color(hex: 0x1B8188), Color(hex: 0x0E4F54)]),
dark: .init(colors: [Color(hex: 0x3DBCC4), Color(hex: 0x1B8188)])
)
)
}That's it — your theme is ready to use.
Advanced
Beyond light/dark, tokens can adapt by size class or hold a constant value:
// Adapt by horizontal size class (compact vs regular)
surface: .init(compact: Color(hex: 0xFFFFFF), regular: Color(hex: 0xF7F5EC))
// Same value in all environments
surface: .init(value: Color(hex: 0xF7F5EC))For full control, use a custom resolver — a closure that reads EnvironmentValues directly:
surface: .init(resolver: .init(id: "high-contrast") { env in
env.colorSchemeContrast == .increased
? Color(hex: 0xFFFFFF)
: Color(hex: 0xF7F5EC)
})The id parameter drives Equatable — two resolvers with the same id are considered equal, which lets SwiftUI skip unnecessary redraws. Omit it to get a unique auto-generated id.
Note: Tokens created with a custom resolver cannot be encoded to JSON, since they have no serializable defaults.
Theme tokens work just like built-in SwiftUI styles:
Text("Hello, world")
.foregroundStyle(.onSurface)
RoundedRectangle(cornerRadius: 12)
.fill(.surface)
Circle()
.fill(.primaryColor)When your config includes shadow tokens, you can chain them onto any style:
RoundedRectangle(cornerRadius: 12)
.fill(.surface.card) // theme color + theme shadow
RoundedRectangle(cornerRadius: 12)
.fill(.red.card) // SwiftUI color + theme shadow
RoundedRectangle(cornerRadius: 12)
.fill(.surface.card.innerGlow) // multiple shadows chainedThe generated Environment+Theme.swift provides implicit theme injection — every token resolves against Theme.default automatically, so things just work with no setup. When you need to switch themes at runtime, override the environment value:
struct MyApp: App {
@State private var theme: Theme = .default
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.theme, theme)
}
}
}Define alternative themes using copyWith for immutable updates:
extension Theme {
static let ocean = Theme.default.copyWith(
colors: ThemeColors.default.copyWith(
primary: .init(light: .blue, dark: .cyan)
)
)
}Then swap themes by updating the state:
Button("Ocean Theme") {
theme = .ocean
}Every type conforms to Codable, so themes can come from a remote API, a bundled file, or user preferences:
let theme = try JSONDecoder().decode(Theme.self, from: data)The generated ThemeShapeStyle<Style> bridges your tokens into SwiftUI's style resolution system. It holds a key path into Theme and resolves the correct variant at render time:
struct ThemeShapeStyle<Style: ShapeStyle>: ShapeStyle {
let keyPath: KeyPath<Theme, ThemeAdaptiveStyle<Style>>
func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
environment.theme[keyPath: keyPath].resolved(in: environment)
}
}This is the same resolve(in:) mechanism that powers SwiftUI's built-in .primary, .tint, and other environment-dependent styles. Your tokens participate in the system as first-class citizens.
For a deep dive, see the full blog post or the implementation details.