Design tokens preserve intent across appearances. A semantic name such as primaryText describes purpose; a literal hex value describes only one rendering choice.
- 1Primitive palette
- 2Semantic roles
- 3Component usage
- 4Appearance checks
Work through the example
Trace one button from its semantic color to its light, dark and high-contrast values. Remove literal colors only after understanding their role.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
1. The Three-Tier Token Architecture
Never let a raw color or number appear at a call site. Tokens flow in one direction through three tiers:
Tier 1 — Primitive Tier 2 — Semantic Tier 3 — Component
(raw values) (intent) (usage)
blue500 #0A84FF → accent → Button.background
gray900 #1C1C1E → textPrimary → Card.titleColor
space4 16pt → spacing.contentInset → Card.padding
Rules
- Views reference Tier 3 or Tier 2 only. A view that names
blue500is a bug. - Tier 1 is
privateto the token module. It has no dark-mode variant — it is literally just a number. - Tier 2 is where light/dark, high-contrast, and theme switching resolve.
- Adding a theme means adding one Tier 2 implementation, not editing views.
Implementation
// DesignSystem/Tokens/Primitives.swift
// Tier 1 — raw values. Never referenced from a View.
enum Primitive {
static let blue500 = Color(hex: 0x0A84FF)
static let blue600 = Color(hex: 0x0060DF)
static let indigo500 = Color(hex: 0x5E5CE6)
static let red500 = Color(hex: 0xFF3B30)
static let green500 = Color(hex: 0x34C759)
static let amber500 = Color(hex: 0xFF9F0A)
// Spacing scale — a 4pt rhythm. Nothing else is permitted.
static let space1: CGFloat = 4
static let space2: CGFloat = 8
static let space3: CGFloat = 12
static let space4: CGFloat = 16
static let space5: CGFloat = 24
static let space6: CGFloat = 32
static let space7: CGFloat = 48
// Radii
static let radiusS: CGFloat = 8
static let radiusM: CGFloat = 16
static let radiusL: CGFloat = 24
}
// THE canonical hex initialiser for this skill. Defined here, in the token
// layer, and nowhere else — `color-system.md` and
// `templates/common-patterns/design-system.swift` reference this file rather
// than redeclaring it. Two files declaring `init(hex: String)` with different
// bodies is not a style disagreement: copy both into one target and the
// compiler rejects it with `invalid redeclaration of 'init(hex:)'`.
extension Color {
/// Hex as an integer literal: `Color(hex: 0x6C63FF)`.
///
/// Preferred over the string form because a typo is a compile error rather
/// than a runtime surprise — `0x6C63FZ` does not build, `"6C63FZ"` does.
init(hex: UInt32, opacity: Double = 1) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double( hex & 0xFF) / 255,
opacity: opacity
)
}
/// Hex as a string: `Color(hex: "6C63FF")`, with or without `#`,
/// 6 digits (RGB) or 8 (RRGGBBAA).
///
/// Exists because designers hand over strings and remote themes arrive as
/// JSON. A malformed value traps in DEBUG and renders **magenta** in
/// release — never black. The earlier versions of this initialiser fell
/// back to black, which is indistinguishable from a deliberate colour and
/// so shipped unnoticed; magenta appears nowhere in any of these palettes
/// and is impossible to mistake for intent.
init(hex string: String, opacity: Double = 1) {
let cleaned = string
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "#", with: "")
var value: UInt64 = 0
let scanned = Scanner(string: cleaned).scanHexInt64(&value)
switch (scanned, cleaned.count) {
case (true, 6):
self.init(hex: UInt32(truncatingIfNeeded: value), opacity: opacity)
case (true, 8):
let alpha = Double(value & 0xFF) / 255
self.init(hex: UInt32(truncatingIfNeeded: value >> 8), opacity: opacity * alpha)
default:
assertionFailure("Malformed hex colour literal: \(string)")
self.init(hex: 0xFF00FF, opacity: opacity)
}
}
}
// DesignSystem/Tokens/Theme.swift
// Tier 2 — semantic intent. This is the swappable layer.
protocol Theme: Sendable {
// Surfaces
var background: Color { get } // the page
var surface: Color { get } // cards, sheets
var surfaceElevated: Color { get } // popovers, menus
// Content — must meet contrast against the surface it sits on
var textPrimary: Color { get }
var textSecondary: Color { get }
var textOnAccent: Color { get }
// Intent
var accent: Color { get }
var accentPressed: Color { get }
var destructive: Color { get }
var success: Color { get }
var warning: Color { get }
// Separators and elevation
var separator: Color { get }
var shadow: Color { get }
}
struct OceanTheme: Theme {
// Apple's semantic colors already resolve light/dark AND increased contrast.
// Prefer them for surfaces and text; reserve custom hex for brand accents.
var background = Color(.systemBackground)
var surface = Color(.secondarySystemBackground)
var surfaceElevated = Color(.tertiarySystemBackground)
var textPrimary = Color(.label)
var textSecondary = Color(.secondaryLabel)
var textOnAccent = Color.white
var accent = Primitive.blue500
var accentPressed = Primitive.blue600
var destructive = Color(.systemRed)
var success = Color(.systemGreen)
var warning = Color(.systemOrange)
var separator = Color(.separator)
var shadow = Color.black.opacity(0.08)
}
// DesignSystem/Tokens/Spacing.swift — Tier 2 for layout
enum Space {
static let hairline = Primitive.space1 // icon-to-label
static let tight = Primitive.space2 // within a control
static let element = Primitive.space3 // between related elements
static let contentInset = Primitive.space4 // card padding, screen margins
static let section = Primitive.space5 // between sections
static let major = Primitive.space6 // above a page title
}
enum Radius {
static let control = Primitive.radiusS // buttons, chips
static let card = Primitive.radiusM // cards, tiles
static let sheet = Primitive.radiusL // modals
}
Injecting the theme
private struct ThemeKey: EnvironmentKey {
static let defaultValue: any Theme = OceanTheme()
}
extension EnvironmentValues {
var theme: any Theme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
RootView().environment(\.theme, OceanTheme())
}
}
}
Tier 3 — component tokens as ViewModifiers
struct CardStyle: ViewModifier {
@Environment(\.theme) private var theme
func body(content: Content) -> some View {
content
.padding(Space.contentInset)
.background(theme.surface, in: .rect(cornerRadius: Radius.card))
.shadow(color: theme.shadow, radius: 8, y: 4)
}
}
extension View {
func cardStyle() -> some View { modifier(CardStyle()) }
}
// Usage — no raw values anywhere.
VStack(alignment: .leading, spacing: Space.element) {
Text("Monthly total").font(.headline).foregroundStyle(theme.textPrimary)
Text("$1,240").font(.largeTitle.bold()).foregroundStyle(theme.accent)
}
.cardStyle()
Anti-patterns
// WRONG — raw values at the call site. Changing the brand means grepping.
.padding(16)
.background(Color(red: 0.04, green: 0.52, blue: 1.0))
.cornerRadius(16)
// WRONG — a Tier 1 primitive leaking into a view.
.foregroundStyle(Primitive.blue500)
// WRONG — a semantic name that describes appearance, not intent.
var lightGray: Color { … } // what happens in dark mode?
var textSecondary: Color { … } // correct
// RIGHT
.padding(Space.contentInset)
.background(theme.accent, in: .rect(cornerRadius: Radius.card))
Acceptance and failure review
| Checkpoint | What to inspect | If it does not match |
|---|---|---|
| Primitive palette | Confirm the input and environment | Preserve the failure and return to this step |
| Semantic roles | Inspect the intermediate artifact | Preserve the failure and return to this step |
| Component usage | Run the focused check | Preserve the failure and return to this step |
| Appearance checks | Record the observed result | Preserve the failure and return to this step |
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Generated asset structure has evidence; visual quality still needs checks in the actual app.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The series evidence record separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson.
Related reading
- How can design tokens generate light, dark and high-contrast color assets?
Generate a real xcassets catalog from explicit color tokens, inspect its four variants, and compile it with actool.
- Generate light and dark asset catalogs without a paid design tool
Turn explicit color tokens into Assets.xcassets, then inspect the result in your app.
What to do next
Next: SwiftUI prompt practice: 25 scoped exercises and acceptance checks