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
nonisolatedextensionTheme{staticlet`default`=Theme(
colors:.default,
gradients:.default
)}
// MARK: - ThemeColors
nonisolatedextensionThemeColors{staticlet`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
nonisolatedextensionThemeGradients{staticlet`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:
structMyApp:App{@Stateprivatevartheme:Theme=.default
varbody:someScene{WindowGroup{ContentView().environment(\.theme, theme)}}}Define alternative themes using copyWith for immutable updates:
extensionTheme{staticletocean=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:
lettheme=tryJSONDecoder().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:
structThemeShapeStyle<Style:ShapeStyle>:ShapeStyle{letkeyPath:KeyPath<Theme,ThemeAdaptiveStyle<Style>>func resolve(in environment:EnvironmentValues)->someShapeStyle{
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.