
Axiom Design
- 729 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-design is a Claude Code skill that applies Apple Human Interface Guidelines, SF Symbols, typography, and adaptive layouts for developers building native SwiftUI or UIKit screens.
About
axiom-design is an Axiom skill that guides native Apple UI development with Human Interface Guidelines, SF Symbols, typography systems, and adaptive layouts for SwiftUI and UIKit. Developers reach for axiom-design when building iOS, iPadOS, or macOS screens that must feel platform-native rather than cross-platform generic. The skill encodes HIG spacing, symbol usage, dynamic type, and size-class-aware layout decisions so agent-generated SwiftUI views and UIKit view controllers align with Apple's design language. Use it during screen scaffolding, component polish, or accessibility pass reviews on native Apple targets.
- Human Interface Guidelines alignment
- SF Symbols and system typography
- Adaptive layout for size classes
- Accessibility and Dynamic Type
- Native component selection
Axiom Design by the numbers
- 729 all-time installs (skills.sh)
- Ranked #498 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-designAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 729 |
|---|---|
| repo stars | ★ 1.1k |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you apply Apple HIG in SwiftUI screens?
Apply Human Interface Guidelines, SF Symbols, typography, and adaptive layouts when building native SwiftUI or UIKit screens.
Who is it for?
iOS developers building SwiftUI or UIKit screens who want agent-guided Apple HIG, SF Symbols, and adaptive layout compliance.
Skip if: Cross-platform React Native or Flutter UIs where Apple HIG and native SF Symbol patterns are not the primary design target.
When should I use this skill?
The user builds or refines native SwiftUI or UIKit screens needing HIG, SF Symbols, typography, or adaptive layout guidance.
What you get
HIG-compliant SwiftUI or UIKit views, SF Symbol integrations, typography scales, and adaptive layout implementations.
- HIG-compliant view code
- Adaptive layout implementations
Files
Design & HIG
You MUST use this skill for ANY visual design, HIG compliance, Liquid Glass, SF Symbols, typography, or app composition work.
Quick Reference
| Symptom / Task | Reference |
|---|---|
| Design decisions, HIG compliance, colors, backgrounds | See skills/hig.md |
| Semantic colors, custom color patterns, material styles | See skills/hig-ref.md |
| Liquid Glass effects, adoption, migration from blur effects | See skills/liquid-glass.md |
| App-wide Liquid Glass adoption, backward compatibility | See skills/liquid-glass-ref.md |
| SF Symbols rendering modes, effects, animations | See skills/sf-symbols.md |
| SF Symbols API signatures, UIKit equivalents, availability | See skills/sf-symbols-ref.md |
| San Francisco fonts, text styles, Dynamic Type, tracking | See skills/typography-ref.md |
| App entry points, auth flows, root view switching, scene lifecycle, document-based apps | See skills/app-composition.md |
| Apple Pay button / Wallet pass design / Tap to Pay button | See axiom-payments suite, plus skills/hig.md for cross-cutting HIG context |
Decision Tree
digraph design {
start [label="Design task" shape=ellipse];
what [label="What do you need?" shape=diamond];
start -> what;
what -> "skills/hig.md" [label="design decision,\nHIG compliance,\ncolor/background choice"];
what -> "skills/hig-ref.md" [label="semantic color API,\ncustom color code,\nmaterial style details"];
what -> "skills/liquid-glass.md" [label="Liquid Glass effects,\nmigrate from blur,\nRegular vs Clear"];
what -> "skills/liquid-glass-ref.md" [label="app-wide Liquid Glass plan,\nplatform differences,\nbackward compat"];
what -> "skills/sf-symbols.md" [label="rendering mode choice,\nsymbol effects/animations,\ncustom symbols"];
what -> "skills/sf-symbols-ref.md" [label="SF Symbols API syntax,\nUIKit equivalents,\navailability matrix"];
what -> "skills/typography-ref.md" [label="font selection,\nDynamic Type,\ntext styles, tracking"];
what -> "skills/app-composition.md" [label="@main entry point,\nauth flow, root view,\nscene lifecycle"];
}1. Design decision / HIG compliance / choosing colors or backgrounds? → skills/hig.md 1a. Need semantic color API, custom color code, or material style details? → skills/hig-ref.md 2. Liquid Glass effects / migrating from blur / Regular vs Clear variant? → skills/liquid-glass.md 2a. Planning app-wide Liquid Glass adoption / platform differences / backward compatibility? → skills/liquid-glass-ref.md 3. SF Symbols rendering mode / symbol effects / custom symbols? → skills/sf-symbols.md 3a. Need SF Symbols API syntax / UIKit equivalents / availability check? → skills/sf-symbols-ref.md 4. Font selection / Dynamic Type / text styles / tracking / leading? → skills/typography-ref.md 5. App entry point / auth flow / root view switching / scene lifecycle? → skills/app-composition.md 6. SwiftUI view implementation? → /skill axiom-swiftui 7. TextKit / rich text editing / Writing Tools? → /skill axiom-uikit 8. Accessibility compliance (VoiceOver, contrast, touch targets)? → /skill axiom-accessibility 9. Audit UI for Liquid Glass adoption? → liquid-glass-auditor (Agent — surfaces migration opportunities AND adoption-completeness gaps: variant discipline, nesting hygiene, availability gating, primary-action tinting, accessibility re-check; scores ADOPTED / PARTIAL / NOT ADOPTED) 10. CarPlay app design, categories, driver-distraction rules? → /skill axiom-media (carplay-hig.md)
Platform-specific HIG
- watchOS design (glanceable UI, watchOS 10 navigation) → See axiom-watchos (skills/design-for-watchos.md)
Conflict Resolution
design vs swiftui: When building UI: 1. Use design FIRST — Decide what to build (colors, materials, typography, layout intent) before how to build it. 2. Then use swiftui — Implement the design decision in SwiftUI code.
design vs accessibility: When choosing colors or typography:
- Color contrast or Dynamic Type compliance? → use accessibility
- Which semantic color or text style to pick? → use design
design (liquid-glass) vs swiftui: When implementing Liquid Glass:
- What Liquid Glass is, when to use Regular vs Clear, migration strategy → use design (
skills/liquid-glass.md) - SwiftUI code for
.glassEffect()modifier → use design (skills/liquid-glass-ref.md), then swiftui for surrounding view code
design (app-composition) vs swiftui: When structuring app architecture:
- @main entry, auth state machine, root view switching, scene lifecycle → use design (
skills/app-composition.md) - NavigationStack, NavigationSplitView, tab structure → use swiftui
design vs media (CarPlay): When designing for CarPlay:
- General iOS HIG principles (colors, typography, Liquid Glass) → use design
- CarPlay-specific rules (app categories, entitlement review, template-only UI, driver distraction, per-category design rules) → invoke axiom-media (
skills/carplay-hig.md) - CarPlay rules are stricter than iOS HIG and enforced at entitlement review, not just App Store review.
Critical Patterns
HIG Quick Decisions (skills/hig.md):
- Background color decision tree (media-focused vs standard)
- Typography selection (headline vs body vs caption)
- Color usage guidelines and when to use semantic vs custom colors
- Design review checklist for HIG compliance
HIG Comprehensive Reference (skills/hig-ref.md):
- All semantic colors with platform availability
- Custom color patterns with dark mode support
- Background hierarchy and material styles
- Code examples for every color and background pattern
Liquid Glass (skills/liquid-glass.md):
- What Liquid Glass is and how it differs from blur effects
- Regular vs Clear variant selection
- Migration strategy from pre-iOS 26 materials
- Tinting, legibility, and adaptive behavior troubleshooting
- Expert review criteria for Liquid Glass implementations
Liquid Glass Adoption (skills/liquid-glass-ref.md):
- App-wide adoption planning (icons, controls, navigation, menus)
- Platform-specific behavior (iOS, iPadOS, macOS, tvOS, watchOS)
- Backward compatibility strategy for supporting pre-Liquid Glass
- Accessibility compliance with Liquid Glass interfaces
SF Symbols (skills/sf-symbols.md):
- Rendering mode selection (Monochrome, Hierarchical, Palette, Multicolor)
- Symbol effect selection (Bounce, Pulse, Scale, Wiggle, Rotate, Breathe, Draw)
- Custom symbol creation workflow
- Troubleshooting effects not playing, weight mismatches
SF Symbols API (skills/sf-symbols-ref.md):
- Exact API signatures for rendering modes and effects
- UIKit/AppKit equivalents for every SwiftUI symbol API
- Platform availability matrix
- Configuration options (weight, scale, variable values)
Typography (skills/typography-ref.md):
- San Francisco font system (Pro, Compact, Mono, New York)
- Text styles with Dynamic Type scaling
- Tracking and leading values
- Internationalization considerations
App Composition (skills/app-composition.md):
- @main entry point and root view patterns
- Authentication state machine (login, onboarding, main)
- Flicker-free root view switching
- scenePhase lifecycle handling and state restoration
- Document-based apps: the
OS27@Observabledocument model (ReadableDocument/WritableDocument) +DocumentGroup
Anti-Rationalization
| Thought | Reality |
|---|---|
| "I'll just pick colors that look good" | Semantic colors adapt to dark mode, accessibility settings, and platform automatically. Custom colors need all of that manually. skills/hig.md has the decision tree. |
| "Liquid Glass is just a blur effect" | Liquid Glass is a distinct material system with lensing, tinting, and adaptive behavior. Using .blur() instead creates a visually wrong result. skills/liquid-glass.md explains the difference. |
| "I know which SF Symbol rendering mode to use" | The right mode depends on context (monochrome for toolbars, hierarchical for depth, palette for brand colors). skills/sf-symbols.md has the decision tree. |
| "I'll hardcode font sizes" | Hardcoded sizes break Dynamic Type, violate HIG, and fail accessibility review. skills/typography-ref.md shows the text style system. |
| "I'll handle auth state with a boolean" | A boolean can't represent login, onboarding, and main states without race conditions. skills/app-composition.md has the state machine pattern. |
| "Liquid Glass adoption means rewriting my whole UI" | Most standard SwiftUI/UIKit components adopt automatically. Start by building with latest Xcode, then review. skills/liquid-glass-ref.md has the incremental strategy. |
| "I'll add the SF Symbol animation later" | Symbol effects are the primary way users perceive interactive feedback. Shipping without them feels broken. skills/sf-symbols.md covers selection. |
| "I'll skip the design review, the code works" | HIG compliance affects App Store review. Reviewers reject apps that feel wrong even if they function correctly. skills/hig.md has the review checklist. |
Example Invocations
User: "Should I use a dark or light background?" -> Read: skills/hig.md
User: "What semantic color should I use for secondary text?" -> Read: skills/hig-ref.md
User: "How do I implement Liquid Glass in my app?" -> Read: skills/liquid-glass.md
User: "I need to plan Liquid Glass adoption across my whole app" -> Read: skills/liquid-glass-ref.md
User: "My SF Symbol is flat, I want it to have depth" -> Read: skills/sf-symbols.md
User: "What's the SwiftUI API for symbol effects?" -> Read: skills/sf-symbols-ref.md
User: "Which font should I use for body text?" -> Read: skills/typography-ref.md
User: "How do I switch between login and main screens?" -> Read: skills/app-composition.md
User: "Check my app's UI for HIG compliance" -> Read: skills/hig.md, then /skill axiom-accessibility for contrast/Dynamic Type
User: "I want my download button icon to animate" -> Read: skills/sf-symbols.md
App Composition
When to Use This Skill
Use this skill when:
- Structuring your @main entry point and root view
- Managing authentication state (login → onboarding → main)
- Switching between app-level states without flicker
- Handling scene lifecycle events (scenePhase)
- Restoring app state after termination
- Deciding when to split into feature modules
- Coordinating between multiple windows (iPad, visionOS)
Example Prompts
| What You Might Ask | Why This Skill Helps |
|---|---|
| "How do I switch between login and main screens?" | AppStateController pattern with validated transitions |
| "My app flickers when switching from splash to main" | Flicker prevention with animation coordination |
| "Where should auth state live?" | App-level state machine, not scattered booleans |
| "How do I handle app going to background?" | scenePhase lifecycle patterns |
| "When should I split my app into modules?" | Decision tree based on codebase size and team |
| "How do I restore state after app is killed?" | SceneStorage and state validation patterns |
Quick Decision Tree
What app-level architecture question are you solving?
│
├─ How do I manage app states (loading, auth, main)?
│ └─ Part 1: App-Level State Machines
│ - Enum-based state with validated transitions
│ - AppStateController pattern
│ - Prevents "boolean soup" anti-pattern
│
├─ How do I structure @main and root view switching?
│ └─ Part 2: Root View Switching Patterns
│ - Delegate to AppStateController (no logic in @main)
│ - Flicker prevention with animation
│ - Coordinator integration
│
├─ How do I handle scene lifecycle?
│ └─ Part 3: Scene Lifecycle Integration
│ - scenePhase for session validation
│ - SceneStorage for restoration
│ - Multi-window coordination
│
├─ When should I modularize?
│ └─ Part 4: Feature Module Basics
│ - Decision tree by size/team
│ - Module boundaries and DI
│ - Navigation coordination
│
└─ What mistakes should I avoid?
└─ Part 5: Anti-Patterns + Part 6: Pressure Scenarios
- Boolean-based state
- Logic in @main
- Missing restoration validation---
Part 1: App-Level State Machines
Core Principle
"Apps have discrete states. Model them explicitly with enums, not scattered booleans."
Every non-trivial app has distinct states: loading, unauthenticated, onboarding, authenticated, error recovery. These states should be: 1. Explicit — An enum, not multiple booleans 2. Validated — Transitions are checked and logged 3. Centralized — One source of truth 4. Observable — Views react to state changes
The Boolean Soup Problem
// ❌ Boolean soup — impossible to validate, prone to invalid states
class AppState {
var isLoading = true
var isLoggedIn = false
var hasCompletedOnboarding = false
var hasError = false
var user: User?
// What if isLoading && isLoggedIn && hasError are all true?
// Invalid state, but nothing prevents it
}Problems
- No compile-time guarantee of valid states
- Easy to forget to update one boolean
- Testing requires checking all combinations
- Race conditions create impossible states
The AppStateController Pattern
Step 1: Define Explicit States
enum AppState: Equatable {
case loading
case unauthenticated
case onboarding(OnboardingStep)
case authenticated(User)
case error(AppError)
}
enum OnboardingStep: Equatable {
case welcome
case permissions
case profileSetup
case complete
}
enum AppError: Equatable {
case networkUnavailable
case sessionExpired
case maintenanceMode
}Step 2: Create the Controller
@Observable
@MainActor
class AppStateController {
private(set) var state: AppState = .loading
// MARK: - State Transitions
func transition(to newState: AppState) {
guard isValidTransition(from: state, to: newState) else {
assertionFailure("Invalid transition: \(state) → \(newState)")
logInvalidTransition(from: state, to: newState)
return
}
let oldState = state
state = newState
logTransition(from: oldState, to: newState)
}
// MARK: - Validation
private func isValidTransition(from: AppState, to: AppState) -> Bool {
switch (from, to) {
// From loading
case (.loading, .unauthenticated): return true
case (.loading, .authenticated): return true
case (.loading, .error): return true
// From unauthenticated
case (.unauthenticated, .onboarding): return true
case (.unauthenticated, .authenticated): return true
case (.unauthenticated, .error): return true
// From onboarding
case (.onboarding, .onboarding): return true // Step changes
case (.onboarding, .authenticated): return true
case (.onboarding, .unauthenticated): return true // Cancelled
// From authenticated
case (.authenticated, .unauthenticated): return true // Logout
case (.authenticated, .error): return true
// From error
case (.error, .loading): return true // Retry
case (.error, .unauthenticated): return true
default: return false
}
}
// MARK: - Logging
private func logTransition(from: AppState, to: AppState) {
#if DEBUG
print("AppState: \(from) → \(to)")
#endif
}
private func logInvalidTransition(from: AppState, to: AppState) {
// Log to analytics for debugging
Analytics.log("InvalidStateTransition", properties: [
"from": String(describing: from),
"to": String(describing: to)
])
}
}Step 3: Initialize from Storage
extension AppStateController {
func initialize() async {
// Check for stored session
if let session = await SessionStorage.loadSession() {
// Validate session is still valid
do {
let user = try await AuthService.validateSession(session)
transition(to: .authenticated(user))
} catch {
// Session expired or invalid
await SessionStorage.clearSession()
transition(to: .unauthenticated)
}
} else {
transition(to: .unauthenticated)
}
}
}State Machine Diagram
┌─────────────────────────────────────────────────────────────┐
│ .loading │
└────────────┬───────────────┬────────────────┬───────────────┘
│ │ │
▼ ▼ ▼
.unauthenticated .authenticated .error
│ │ │
▼ │ │
.onboarding ─────────►│◄───────────────┘
│ │
└───────────────┘Testing State Machines
@Test func testValidTransitions() async {
let controller = AppStateController()
// Loading → Unauthenticated (valid)
controller.transition(to: .unauthenticated)
#expect(controller.state == .unauthenticated)
// Unauthenticated → Authenticated (valid)
let user = User(id: "1", name: "Test")
controller.transition(to: .authenticated(user))
#expect(controller.state == .authenticated(user))
}
@Test func testInvalidTransitionRejected() async {
let controller = AppStateController()
// Loading → Onboarding (invalid — must go through unauthenticated)
controller.transition(to: .onboarding(.welcome))
#expect(controller.state == .loading) // Unchanged
}
@Test func testSessionExpiredTransition() async {
let controller = AppStateController()
let user = User(id: "1", name: "Test")
controller.transition(to: .authenticated(user))
// Authenticated → Error (session expired)
controller.transition(to: .error(.sessionExpired))
#expect(controller.state == .error(.sessionExpired))
// Error → Unauthenticated (force re-login)
controller.transition(to: .unauthenticated)
#expect(controller.state == .unauthenticated)
}The State-as-Bridge Pattern (WWDC 2025/266)
From WWDC 2025's "Explore concurrency in SwiftUI":
"Find the boundaries between UI code that requires time-sensitive changes, and long-running async logic."
The key insight: synchronous state changes drive UI (for animations), async code lives in the model (testable without SwiftUI), and state bridges the two.
// ✅ State-as-Bridge: UI triggers state, model does async work
struct ColorExtractorView: View {
@State private var model = ColorExtractor()
var body: some View {
Button("Extract Colors") {
// ✅ Synchronous state change triggers animation
withAnimation { model.isExtracting = true }
// Async work happens in Task
Task {
await model.extractColors()
// ✅ Synchronous state change ends animation
withAnimation { model.isExtracting = false }
}
}
.scaleEffect(model.isExtracting ? 1.5 : 1.0)
}
}
@Observable
class ColorExtractor {
var isExtracting = false
var colors: [Color] = []
func extractColors() async {
// Heavy computation happens here, testable without SwiftUI
let extracted = await heavyComputation()
colors = extracted
}
}Why this matters for app composition
- App-level state changes (loading → authenticated) should be synchronous
- Heavy work (session validation, data loading) should be async in the model
- This separation makes state machines testable without SwiftUI imports
---
Part 2: Root View Switching Patterns
Core Principle
"The @main entry point should be a thin shell. All logic belongs in AppStateController."
The Clean @main Pattern
@main
struct MyApp: App {
@State private var appState = AppStateController()
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.task {
await appState.initialize()
}
}
}
}What @main does
- Creates AppStateController
- Injects it via environment
- Triggers initialization
What @main does NOT do
- Business logic
- Auth checks
- Conditional rendering
- Navigation decisions
RootView: The State Switch
struct RootView: View {
@Environment(AppStateController.self) private var appState
var body: some View {
Group {
switch appState.state {
case .loading:
LaunchView()
case .unauthenticated:
AuthenticationFlow()
case .onboarding(let step):
OnboardingFlow(step: step)
case .authenticated(let user):
MainTabView(user: user)
case .error(let error):
ErrorRecoveryView(error: error)
}
}
}
}Testability Benefit
The thin-shell pattern enables up to 60x faster tests. When app logic lives in a Swift Package instead of the app target, tests run with swift test (~0.4s) vs xcodebuild test (~25s) — no simulator, no app launch.
| Component | Location | Tested With |
|---|---|---|
| Business logic, models, services | Swift Package (MyAppCore) | swift test (0.4s) |
| Root view composition | App target (thin shell) | xcodebuild test (25s) |
See axiom-testing (swift-testing reference) Strategy 1 for the complete package extraction walkthrough.
Preventing Flicker During Transitions
Problem: Flash of Wrong Content
When app state changes, you might see a flash of the old screen before the new one appears. This happens when:
- State changes before view is ready
- No transition animation
- Loading state too short to perceive
Solution: Animated Transitions
struct RootView: View {
@Environment(AppStateController.self) private var appState
var body: some View {
ZStack {
switch appState.state {
case .loading:
LaunchView()
.transition(.opacity)
case .unauthenticated:
AuthenticationFlow()
.transition(.opacity)
case .onboarding(let step):
OnboardingFlow(step: step)
.transition(.opacity)
case .authenticated(let user):
MainTabView(user: user)
.transition(.opacity)
case .error(let error):
ErrorRecoveryView(error: error)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.3), value: appState.state)
}
}Minimum Loading Duration
For a polished experience, ensure the loading screen is visible long enough:
extension AppStateController {
func initialize() async {
let startTime = Date()
// Do actual initialization
await performInitialization()
// Ensure minimum display time for loading screen
let elapsed = Date().timeIntervalSince(startTime)
let minimumDuration: TimeInterval = 0.5
if elapsed < minimumDuration {
try? await Task.sleep(for: .seconds(minimumDuration - elapsed))
}
}
}Coordinator Integration
If using coordinators, integrate them at the root level:
struct RootView: View {
@Environment(AppStateController.self) private var appState
@State private var authCoordinator = AuthCoordinator()
@State private var mainCoordinator = MainCoordinator()
var body: some View {
Group {
switch appState.state {
case .loading:
LaunchView()
case .unauthenticated, .onboarding:
AuthenticationFlow()
.environment(authCoordinator)
case .authenticated(let user):
MainTabView(user: user)
.environment(mainCoordinator)
case .error(let error):
ErrorRecoveryView(error: error)
}
}
.animation(.easeInOut(duration: 0.3), value: appState.state)
}
}---
Part 3: Scene Lifecycle Integration
Core Principle
"Scene lifecycle events are app-wide concerns handled centrally, not scattered across features."
Understanding ScenePhase (Apple Documentation)
ScenePhase indicates a scene's operational state. How you interpret the value depends on where it's read.
Read from a View → Returns the phase of the enclosing scene Read from App → Returns an aggregate value reflecting all scenes
| Phase | Description |
|---|---|
.active | Scene is in the foreground and interactive |
.inactive | Scene is in the foreground but should pause work |
.background | Scene isn't visible; app may terminate soon |
Critical insight from Apple docs When reading at the App level, .active means any scene is active, and .background means all scenes are in background.
scenePhase Handling
@main
struct MyApp: App {
@State private var appState = AppStateController()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.task {
await appState.initialize()
}
}
.onChange(of: scenePhase) { oldPhase, newPhase in
handleScenePhaseChange(from: oldPhase, to: newPhase)
}
}
private func handleScenePhaseChange(from: ScenePhase, to: ScenePhase) {
switch to {
case .active:
// App became active — validate session, refresh data
Task {
await appState.validateSession()
await appState.refreshIfNeeded()
}
case .inactive:
// App about to go inactive — save state
appState.prepareForBackground()
case .background:
// App in background — release resources
appState.releaseResources()
@unknown default:
break
}
}
}Session Validation on Active
extension AppStateController {
func validateSession() async {
guard case .authenticated(let user) = state else { return }
do {
// Check if token is still valid
let isValid = try await AuthService.validateToken(user.token)
if !isValid {
transition(to: .error(.sessionExpired))
}
} catch {
// Network error — keep authenticated but show warning
// Don't immediately log out on transient network issues
}
}
func prepareForBackground() {
// Save any pending data
// Cancel non-essential network requests
// Prepare for potential termination
}
func releaseResources() {
// Release cached images
// Stop location updates if not essential
// Reduce memory footprint
}
}SceneStorage for State Restoration
From Apple documentation: SceneStorage provides automatic state restoration. The system manages saving and restoring on your behalf.
Key constraints
- Keep data lightweight (not full models)
- Each Scene has its own storage (not shared)
- Data destroyed when scene is explicitly destroyed
struct MainTabView: View {
@SceneStorage("selectedTab") private var selectedTab = 0
@SceneStorage("lastViewedItemID") private var lastViewedItemID: String?
var body: some View {
TabView(selection: $selectedTab) {
HomeTab()
.tag(0)
SearchTab()
.tag(1)
ProfileTab()
.tag(2)
}
.onAppear {
if let itemID = lastViewedItemID {
// Restore to last viewed item
navigateToItem(itemID)
}
}
}
}Navigation State Restoration (WWDC 2022/10054)
For complex navigation, use a Codable NavigationModel:
// Encapsulate navigation state with Codable conformance
class NavigationModel: ObservableObject, Codable {
@Published var selectedCategory: Category?
@Published var recipePath: [Recipe] = []
enum CodingKeys: String, CodingKey {
case selectedCategory
case recipePathIds
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory)
// Store only IDs, not full models
try container.encode(recipePath.map(\.id), forKey: .recipePathIds)
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.selectedCategory = try container.decodeIfPresent(
Category.self, forKey: .selectedCategory)
let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds)
// compactMap discards deleted items gracefully
self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] }
}
var jsonData: Data? {
get { try? JSONEncoder().encode(self) }
set {
guard let data = newValue,
let model = try? JSONDecoder().decode(NavigationModel.self, from: data)
else { return }
self.selectedCategory = model.selectedCategory
self.recipePath = model.recipePath
}
}
}
// Use with SceneStorage
struct ContentView: View {
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var data: Data?
var body: some View {
NavigationSplitView { /* ... */ }
.task {
if let data = data {
navModel.jsonData = data
}
for await _ in navModel.objectWillChangeSequence {
data = navModel.jsonData
}
}
}
}Key patterns from WWDC
- Store IDs only, not full model objects
- Use
compactMapto handle deleted items gracefully - Save on every
objectWillChangefor real-time persistence
Validating Restored State
Never trust restored state blindly:
struct DetailView: View {
@SceneStorage("detailItemID") private var restoredItemID: String?
@State private var item: Item?
var body: some View {
Group {
if let item {
ItemContent(item: item)
} else {
ProgressView()
}
}
.task {
if let itemID = restoredItemID {
// Validate item still exists
item = await ItemService.fetch(itemID)
if item == nil {
// Item was deleted — clear restoration
restoredItemID = nil
}
}
}
}
}Multi-Window Coordination (iPad, visionOS)
From Apple documentation: Every window in a WindowGroup maintains independent state. The system allocates new storage for @State and @StateObject for each window.
@main
struct MyApp: App {
@State private var appState = AppStateController()
var body: some Scene {
// Primary window
WindowGroup {
MainView()
.environment(appState)
}
// Data-presenting window (iPad)
// Prefer lightweight data (IDs, not full models)
WindowGroup("Detail", id: "detail", for: Item.ID.self) { $itemID in
if let itemID {
DetailView(itemID: itemID)
.environment(appState)
}
}
#if os(visionOS)
// Immersive space
ImmersiveSpace(id: "immersive") {
ImmersiveView()
.environment(appState)
}
#endif
}
}Key behaviors from Apple docs
- If a window with the same value already exists, the system brings it to front instead of opening a new one
- SwiftUI persists the binding value for state restoration
- Use unique identifier strings for each window group
Opening Additional Windows
struct ItemRow: View {
let item: Item
@Environment(\.openWindow) private var openWindow
var body: some View {
Button(item.title) {
// Open in new window on iPad
// Use ID to match window group, value to pass data
openWindow(id: "detail", value: item.id)
}
}
}Dismissing Windows Programmatically
struct DetailView: View {
var itemID: Item.ID?
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
// ...
Button("Done") {
dismiss() // Closes this window
}
}
}
}---
Part 3b: Document-Based Apps — The OS27 Document Model
DocumentGroup is the Scene for file-backed apps (it wires up File ▸ New/Open, the document browser, and autosave). The 27 cycle adds a reference-type, `@Observable`, async document model alongside the legacy value-type FileDocument — OS27 (not watchOS/tvOS).
Why the new model
Legacy FileDocument / ReferenceFileDocument | ReadableDocument / WritableDocument (OS27) | |
|---|---|---|
| Type | struct (or ObservableObject class) | @Observable class (AnyObject) |
| Read/write | synchronous init(configuration:) / fileWrapper(...) | async read/write with a Foundation.Subprogress for progress reporting |
| State | whole-document value snapshot on every change | the document is your @Observable model — bind to it directly |
| Concurrency | snapshot must be Sendable | snapshot crosses actors via sending; apply/snapshot run on @MainActor |
Reach for the new model when documents are large or slow to load (async + progress), or when you want the document to be your @Observable source of truth instead of mirroring a value type into one. Keep FileDocument for small, simple value-type documents.
Pattern
import SwiftUI
import UniformTypeIdentifiers
@Observable
final class MarkdownDocument: ReadableDocument, WritableDocument {
var text: String = ""
static let readableContentTypes: [UTType] = [.plainText]
// Decode off the main actor (build a snapshot)…
func reader(configuration: ReadConfiguration) -> FileWrapperDocumentReader<String> {
FileWrapperDocumentReader(configuration) { fileWrapper in
String(decoding: fileWrapper.regularFileContents ?? Data(), as: UTF8.self)
}
}
// …then apply it to the model on the main actor.
@MainActor func apply(snapshot: String, previous: String?) async throws {
text = snapshot
}
// Capture a snapshot on the main actor…
@MainActor func snapshot(contentType: UTType) async throws -> String { text }
// …then serialize it off the main actor.
func writer(configuration: WriteConfiguration) -> FileWrapperDocumentWriter<String> {
FileWrapperDocumentWriter(configuration) { snapshot in
FileWrapper(regularFileWithContents: Data(snapshot.utf8))
}
}
}
@main
struct MarkdownApp: App {
var body: some Scene {
DocumentGroup(editor: { document in
TextEditor(text: Bindable(document).text) // bind straight to the @Observable document
}, makeDocument: { configuration, context in
MarkdownDocument()
})
}
}Key points:
- Two new
DocumentGroupinits (both requireDocument: Observable):init(viewer:makeReadableDocument:)for read-only viewers andinit(allowCreating:editor:makeDocument:)for editors.makeDocumentreceives aURLDocumentConfiguration(fileURL,lastContentModificationDate,makeFileCoordinator(), andcreationSourceon iOS/visionOS) and aDocumentCreationContext. FileWrapperDocumentReader/FileWrapperDocumentWriterare the convenience path; conform toDocumentReader/DocumentWriterdirectly for custom I/O (e.g. streaming a package format) and report progress through theSubprogressparameter (theOS27ProgressManagersystem — seeaxiom-concurrency (skills/swift-concurrency.md)).apply(snapshot:previous:)andsnapshot(contentType:)run on@MainActorso they touch your model safely; the reader/writer bodies run off it.DocumentGroupalso surfaces in axiom-macos (skills/windows.md) for the Mac document-app shell (menus,DocumentGroupLaunchScene).
---
Part 4: Feature Module Basics
Core Principle
"Split into modules when features have clear boundaries. Not before."
Premature modularization creates overhead. Late modularization creates pain. Use this decision tree.
When to Modularize Decision Tree
Should I extract this feature into a module?
│
├─ Is the codebase under 5,000 lines with 1-2 developers?
│ └─ NO modularization needed yet
│ Single target is fine, revisit at 10,000 lines
│
├─ Is the codebase 5,000-20,000 lines with 3+ developers?
│ └─ CONSIDER modularization
│ Look for natural boundaries
│
├─ Is the codebase over 20,000 lines?
│ └─ MODULARIZE for build times
│ Parallel compilation essential
│
├─ Could this feature be used in multiple apps?
│ └─ EXTRACT to reusable module
│ Shared authentication, analytics, axiom-networking
│
├─ Do multiple developers work on this feature daily?
│ └─ EXTRACT for merge conflict reduction
│ Isolated codebases = parallel work
│
└─ Does the feature have clear input/output boundaries?
├─ YES → Good candidate for module
└─ NO → Refactor boundaries first, then extractModule Boundary Pattern
Define a Public API
// FeatureModule/Sources/FeatureModule/FeatureAPI.swift
/// Public interface for the feature module
public protocol FeatureAPI {
/// Show the feature's main view
@MainActor
func makeMainView() -> AnyView
/// Handle deep link into feature
@MainActor
func handleDeepLink(_ url: URL) -> Bool
}
/// Factory to create feature with dependencies
public struct FeatureFactory {
public static func create(
analytics: AnalyticsProtocol,
networking: NetworkingProtocol
) -> FeatureAPI {
FeatureImplementation(
analytics: analytics,
networking: axiom-networking
)
}
}Internal Implementation
// FeatureModule/Sources/FeatureModule/Internal/FeatureImplementation.swift
internal class FeatureImplementation: FeatureAPI {
private let analytics: AnalyticsProtocol
private let networking: NetworkingProtocol
internal init(
analytics: AnalyticsProtocol,
networking: NetworkingProtocol
) {
self.analytics = analytics
self.networking = networking
}
@MainActor
public func makeMainView() -> AnyView {
AnyView(FeatureMainView(viewModel: makeViewModel()))
}
public func handleDeepLink(_ url: URL) -> Bool {
// Handle feature-specific deep links
return false
}
private func makeViewModel() -> FeatureViewModel {
FeatureViewModel(analytics: analytics, networking: networking)
}
}Use in Main App
// MainApp/Sources/App/AppDependencies.swift
@Observable
class AppDependencies {
let analytics: AnalyticsProtocol
let networking: NetworkingProtocol
// Lazy-created feature modules
lazy var profileFeature: FeatureAPI = {
ProfileFeatureFactory.create(
analytics: analytics,
networking: axiom-networking
)
}()
lazy var settingsFeature: FeatureAPI = {
SettingsFeatureFactory.create(
analytics: analytics,
networking: axiom-networking
)
}()
}
// MainApp/Sources/App/MainTabView.swift
struct MainTabView: View {
@Environment(AppDependencies.self) private var dependencies
var body: some View {
TabView {
dependencies.profileFeature.makeMainView()
.tabItem { Label("Profile", systemImage: "person") }
dependencies.settingsFeature.makeMainView()
.tabItem { Label("Settings", systemImage: "gear") }
}
}
}Navigation Coordination Between Modules
Features should not know about each other directly:
// ❌ Feature knows about other features
struct ProfileView: View {
func showSettings() {
// ProfileView imports SettingsFeature — circular dependency risk
NavigationLink(value: SettingsDestination())
}
}
// ✅ Feature delegates navigation to coordinator
struct ProfileView: View {
let onShowSettings: () -> Void
func showSettings() {
onShowSettings() // ProfileView doesn't know what happens
}
}
// Coordinator wires features together
class MainCoordinator {
func showSettings(from profile: ProfileFeatureAPI) {
// Coordinator knows about both features
navigationPath.append(SettingsRoute())
}
}Module Folder Structure
MyApp/
├── App/ # Main app target
│ ├── MyApp.swift # @main entry point
│ ├── AppDependencies.swift # Dependency container
│ ├── AppStateController.swift # App state machine
│ └── Coordinators/ # Navigation coordinators
│
├── Packages/
│ ├── Core/ # Shared utilities
│ │ ├── Networking/
│ │ ├── Analytics/
│ │ └── Design/ # Design system
│ │
│ ├── Features/ # Feature modules
│ │ ├── Profile/
│ │ ├── Settings/
│ │ └── Onboarding/
│ │
│ └── Domain/ # Business logic
│ ├── Models/
│ └── Services/---
Part 5: Anti-Patterns
Anti-Pattern 1: Boolean-Based State
// ❌ Boolean soup — impossible to validate
class AppState {
var isLoading = true
var isLoggedIn = false
var hasCompletedOnboarding = false
var hasError = false
}
// What if isLoading && isLoggedIn && hasError are all true?Fix Use enum-based state (Part 1)
// ✅ Explicit states — compiler prevents invalid combinations
enum AppState {
case loading
case unauthenticated
case onboarding(OnboardingStep)
case authenticated(User)
case error(AppError)
}Anti-Pattern 2: Logic in @main
// ❌ Business logic in App entry point
@main
struct MyApp: App {
@State private var user: User?
@State private var isLoading = true
var body: some Scene {
WindowGroup {
if isLoading {
LoadingView()
} else if let user {
MainView(user: user)
} else {
LoginView(onLogin: { self.user = $0 })
}
}
.task {
user = await AuthService.getCurrentUser()
isLoading = false
}
}
}Problems
- @main becomes bloated with logic
- Hard to test without launching app
- State scattered across multiple @State
Fix Delegate to AppStateController (Part 2)
// ✅ @main is a thin shell
@main
struct MyApp: App {
@State private var appState = AppStateController()
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.task { await appState.initialize() }
}
}
}Anti-Pattern 3: Missing State Validation on Restore
// ❌ Trusts restored state blindly
.onAppear {
if let savedState = SceneStorage.appState {
appState.state = savedState // Token might be expired!
}
}Problems
- Session could have expired
- User could have been logged out on another device
- Data could have been deleted
Fix Validate before applying (Part 3)
// ✅ Validates restored state
.task {
if let savedSession = await SessionStorage.loadSession() {
do {
let user = try await AuthService.validateSession(savedSession)
appState.transition(to: .authenticated(user))
} catch {
// Session invalid — force re-login
await SessionStorage.clearSession()
appState.transition(to: .unauthenticated)
}
}
}Anti-Pattern 4: Navigation Logic Scattered Across Features
// ❌ Every feature knows about every other feature
struct ProfileView: View {
@Environment(\.navigationPath) private var path
func showSettings() {
path.append(SettingsDestination()) // ProfileView imports Settings
}
func showOrderHistory() {
path.append(OrderHistoryDestination()) // ProfileView imports Orders
}
}Problems
- Circular dependencies
- Hard to test navigation
- Changes ripple across modules
Fix Delegate to coordinator (Part 4)
// ✅ Feature delegates navigation decisions
struct ProfileView: View {
let onShowSettings: () -> Void
let onShowOrderHistory: () -> Void
// ProfileView doesn't know what these do
}Anti-Pattern 5: God Coordinator
// ❌ Single coordinator knows all features
class AppCoordinator {
func showProfile() { }
func showSettings() { }
func showOnboarding() { }
func showPayment() { }
func showChat() { }
func showOrderHistory() { }
func showNotifications() { }
// ... 50 more methods
}Problems
- Massive file that everyone touches
- Merge conflicts
- Single point of failure
Fix Scoped coordinators
// ✅ Scoped coordinators for each domain
class AuthCoordinator { } // Login, signup, forgot password
class MainCoordinator { } // Tab navigation, main flows
class SettingsCoordinator { } // Settings navigation tree
class OrderCoordinator { } // Order flow, history, details---
Part 5b: UIKit Integration (Incremental Adoption)
For comprehensive bridging patterns (UIViewRepresentable, UIViewControllerRepresentable, UIHostingConfiguration, coordinators, lifecycle, gotchas), see See axiom-uikit (skills/uikit-bridging.md). This section covers app-level integration strategy only.
When This Applies
Most production iOS apps have existing UIKit code. Rewriting everything in SwiftUI is rarely practical. Use these patterns for incremental adoption.
UIHostingController — SwiftUI Inside UIKit
Embed SwiftUI views in an existing UIKit navigation hierarchy:
// Present a SwiftUI view from a UIKit view controller
let settingsView = SettingsView(store: store)
let hostingController = UIHostingController(rootView: settingsView)
navigationController?.pushViewController(hostingController, animated: true)Key rules:
UIHostingControllerowns the SwiftUI view's lifecycle — don't store the root view separately- Use
sizingOptions: .intrinsicContentSizewhen embedding as a child for correct Auto Layout sizing - For sheets:
hostingController.modalPresentationStyle = .pageSheetworks naturally - SwiftUI environment doesn't bridge automatically — inject dependencies through the root view's initializer
UIViewControllerRepresentable — UIKit Inside SwiftUI
Wrap existing UIKit view controllers for use in SwiftUI:
struct DocumentPickerView: UIViewControllerRepresentable {
@Binding var selectedURL: URL?
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.pdf])
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) { }
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UIDocumentPickerDelegate {
let parent: DocumentPickerView
init(_ parent: DocumentPickerView) { self.parent = parent }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
parent.selectedURL = urls.first
}
}
}When to use: Camera UI, document pickers, mail compose, any UIKit controller without a SwiftUI equivalent.
AppDelegate + SwiftUI @main
Bridge UIApplicationDelegate callbacks into a SwiftUI app:
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
RootView()
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Push notification registration, third-party SDK init, etc.
return true
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
// Forward to push notification service
}
}When to use: Push notifications, third-party SDKs requiring AppDelegate, background URL sessions, Handoff.
Migration Priority
When incrementally adopting SwiftUI in a UIKit app:
1. Leaf screens first — Settings, About, detail views (no navigation complexity) 2. New features in SwiftUI — Don't rewrite, but build new screens in SwiftUI 3. Shared components — Build reusable SwiftUI components, wrap in UIHostingController 4. Navigation last — Don't mix UINavigationController with NavigationStack in the same flow; migrate entire navigation subtrees
Don't: Replace UINavigationController with NavigationStack for half the app. Either a flow is fully SwiftUI navigation or fully UIKit navigation.
---
Part 6: Pressure Scenarios
Scenario 1: "Just hardcode the root for now"
The Pressure
"We only have one flow right now. Just show MainView directly, we'll add auth later."
Red Flags
- "We'll add X later" → Tech debt that compounds
- "It's just one flow" → Flows multiply
- "Keep it simple" → Simplicity now, complexity later
Time Cost Comparison
| Option | Initial | When Adding Auth | Total |
|---|---|---|---|
| Hardcode MainView | 0 min | 2-4 hours refactor | 2-4 hours |
| AppStateController | 30 min | 30 min add state | 1 hour |
Push-Back Script
"The AppStateController pattern takes 30 minutes now. When we add auth later — and we will — it'll take another 30 minutes to add the state. Hardcoding now saves 0 minutes because we'll spend 2-4 hours refactoring when we need auth. Let's invest 30 minutes now."
What to Do
1. Create minimal AppStateController with two states:
enum AppState {
case loading
case ready
}2. When auth is needed, add states:
enum AppState {
case loading
case unauthenticated // Added
case authenticated(User) // Added
}3. Total effort: 1 hour instead of 4 hours
---
Scenario 2: "We don't need modules yet"
The Pressure
"Let's keep everything in one target. Modules are over-engineering."
Decision Framework
| Codebase | Team | Recommendation |
|---|---|---|
| < 5,000 lines | 1-2 devs | Single target is fine |
| 5,000-20,000 lines | 3+ devs | Consider modules |
| > 20,000 lines | Any | Modules essential |
Push-Back Script
"I agree modules add overhead. Let's use this decision tree: We have [X] lines and [Y] developers. Based on that, we [should/shouldn't] modularize yet. If we hit [threshold], we'll revisit. Sound good?"
What to Do
1. Check codebase size: find . -name "*.swift" | xargs wc -l 2. If under threshold, document decision and threshold for revisit 3. If over threshold, identify natural boundaries first
---
Scenario 3: "Navigation is too complex to test"
The Pressure
"Testing navigation state is too hard. Let's just do manual QA."
Why This Fails
- Navigation bugs are #1 "works on my machine" cause
- Deep linking requires automated verification
- State restoration needs regression testing
- Manual QA misses edge cases
Solution: Test the State Machine
// ✅ Test navigation state without UI
@Test func testLoginCompletesOnboarding() async {
let controller = AppStateController()
controller.transition(to: .unauthenticated)
// Simulate login
await controller.handleLogin(user: mockUser)
// First-time user goes to onboarding
#expect(controller.state == .onboarding(.welcome))
}
@Test func testDeepLinkWhileUnauthenticated() async {
let controller = AppStateController()
controller.transition(to: .unauthenticated)
// Deep link to order
let handled = controller.handleDeepLink(URL(string: "app://order/123")!)
// Should not navigate — requires auth
#expect(handled == false)
#expect(controller.state == .unauthenticated)
}Push-Back Script
"Navigation is complex, which is exactly why we need automated tests. The AppStateController pattern lets us test state transitions without launching the UI. We can verify deep linking, auth flows, and restoration in seconds. Manual QA can't catch all the combinations."
---
Part 7: Code Review Checklist
App State
- [ ] App state is an enum, not booleans
- [ ] All valid states are explicitly defined
- [ ] State transitions are validated in
isValidTransition - [ ] Invalid transitions are caught (assertion in debug, logged in prod)
- [ ] State changes are logged for debugging
Root View
- [ ] @main delegates to AppStateController
- [ ] No business logic in @main
- [ ] RootView switches on single source of truth
- [ ] Transitions are animated (no flicker)
- [ ] Loading state has minimum display duration
Scene Lifecycle
- [ ] scenePhase changes handled in one place
- [ ] Session validated on .active (not blindly trusted)
- [ ] Resources released on .background
- [ ] SceneStorage used for tab selection / navigation state
- [ ] Restored state validated before applying
- [ ] Document apps (
OS27):@ObservableReadableDocument/WritableDocument, async read/write reports progress,apply/snapshoton@MainActor(see Part 3b)
Module Boundaries
- [ ] Features have public API protocols
- [ ] No circular dependencies between modules
- [ ] Navigation delegates to coordinators
- [ ] Dependencies injected, not singletons
- [ ] Module decision documented (why split / not split)
Testing
- [ ] State transitions tested without UI
- [ ] Invalid transitions tested
- [ ] Deep link handling tested
- [ ] Restoration validation tested
---
Resources
WWDC: 2025-266, 2024-10150, 2023-10149, 2025-256, 2022-10054
Docs: /swiftui/scenephase, /swiftui/scene, /swiftui/scenestorage, /swiftui/windowgroup, /observation/observable(), /swiftui/documentgroup, /swiftui/readabledocument, /swiftui/writabledocument
Skills: axiom-swiftui, axiom-concurrency, axiom-macos (skills/windows.md — Mac document-app shell)
Apple Human Interface Guidelines — Comprehensive Reference
Overview
The Human Interface Guidelines (HIG) define Apple's design philosophy and provide concrete guidance for creating intuitive, accessible, platform-appropriate experiences across all Apple devices.
Three Core Principles
Every design decision should support these principles:
1. Clarity Content is paramount. Interface elements should defer to content, not compete with it. Every element has a purpose, unnecessary complexity is eliminated, and users should immediately know what they can do without extensive instructions.
2. Consistency Apps use standard UI elements and familiar patterns. Navigation follows platform conventions, gestures work as expected, and components appear in expected locations. This familiarity reduces cognitive load.
3. Deference The UI should not distract from essential content. Use subtle backgrounds, receding navigation when not needed, restrained branding, and let content be the hero.
From Apple HIG: "Deference makes an app beautiful by ensuring the content stands out while the surrounding visual elements do not compete with it."
Design System Philosophy
From WWDC25: "A systematic approach means designing with intention at every level, ensuring that all elements, from the tiniest control to the largest surface, are considered in relation to the whole."
Related Skills
- Use
axiom-design (skills/hig.md)for quick decisions and checklists - Use
axiom-design (skills/liquid-glass.md)for iOS 26 material implementation - Use
axiom-design (skills/liquid-glass-ref.md)for iOS 26 app-wide adoption - Use
axiom-accessibilityfor accessibility troubleshooting
---
Color System
Semantic Colors Explained
Instead of hardcoded color values, use semantic colors that describe the purpose of a color rather than its appearance. Semantic colors automatically adapt to light/dark mode and accessibility settings.
Key insight from WWDC19: "Think of Dark Mode as having the lights dimmed rather than everything being flipped inside out." Colors are NOT simply inverted—table row backgrounds are lighter in both modes.
Label Colors (Foreground Content)
Four semantic label levels for text and symbols, each progressively less prominent:
| Style | Semantic Color | Usage |
|---|---|---|
.primary | label | Titles, most prominent text |
.secondary | secondaryLabel | Subtitles, less prominent |
.tertiary | tertiaryLabel | Placeholder text |
.quaternary | quaternaryLabel | Disabled text |
Text("Title").foregroundStyle(.primary) // Black in Light, white in Dark
Text("Subtitle").foregroundStyle(.secondary)Background Colors (Primary → Tertiary)
Background colors come in two sets — ungrouped (standard lists) and grouped (iOS Settings style):
| Level | Ungrouped | Grouped |
|---|---|---|
| Primary | .systemBackground | .systemGroupedBackground |
| Secondary | .secondarySystemBackground | .secondarySystemGroupedBackground |
| Tertiary | .tertiarySystemBackground | .tertiarySystemGroupedBackground |
Ungrouped: pure white/black in Light/Dark. Grouped: light gray/dark in Light/Dark.
// Standard list → ungrouped backgrounds
List { Text("Item") }
.background(Color(.systemBackground))
// Settings-style list → grouped backgrounds
List { Section("Section") { Text("Item") } }
.listStyle(.grouped)Base vs Elevated Backgrounds
There are actually two sets of background colors for layering interfaces:
- Base set: Used for background apps/interfaces
- Elevated set: Used for foreground apps/interfaces
Why this matters:
In Light Mode, simple drop shadows create visual separation. In Dark Mode, drop shadows are less effective, so the system uses lighter colors for elevated content.
Example: iPad multitasking:
- Mail app alone → base color set
- Contacts in slide-over → elevated colors (lighter, stands out)
- Both side-by-side → both use elevated colors for contrast around splitter
- Email compose sheet → elevated colors with overlay dimming
Critical: Some darker colors may not contrast well when elevated. Always test designs in elevated state. Semi-opaque fill and separator colors adapt gracefully.
Tint Colors (Dynamic Adaptation)
Tint colors are dynamic - they have variants for Light and Dark modes:
// Tint color automatically adapts
Button("Primary Action") {
// action
}
.tint(.blue)
// Gets lighter in Dark Mode, darker in Light ModeCustom tint colors: When creating custom tint colors, select colors that work well in both modes. Use a contrast calculator to aim for 4.5:1 or higher contrast ratio. Colors that work in Light Mode may have insufficient contrast in Dark Mode.
Fill Colors (Semi-Transparent)
Fill colors are semi-transparent to contrast well against variable backgrounds:
// System fill colors
Color(.systemFill)
Color(.secondarySystemFill)
Color(.tertiarySystemFill)
Color(.quaternarySystemFill)When to use: Controls, buttons, and interactive elements that need to appear above dynamic backgrounds.
Separator Colors
// Standard separator (semi-transparent)
Color(.separator)
// Opaque separator
Color(.opaqueSeparator)Opaque separators are used when transparency would create undesirable results (e.g., intersecting grid lines where overlapping semi-transparent colors create optical illusions).
When to Use Permanent Dark Backgrounds
Apple's explicit guidance:
"In rare cases, consider using only a dark appearance in the interface. For example, it can make sense for an app that enables immersive media viewing to use a permanently dark appearance that lets the UI recede and helps people focus on the media."
Examples from Apple's apps:
| App | Background | Rationale |
|---|---|---|
| Music | Dark | Album art should be visual focus |
| Photos | Dark | Images are hero content |
| Clock | Dark | Nighttime use, instrument feel |
| Stocks | Dark | Data visualization, charts |
| Camera | Dark | Reduces distraction during capture |
For all other apps: Support both Light and Dark modes via system backgrounds.
Creating Custom Colors
When you need custom colors:
1. Open Assets.xcassets 2. Add Color Set 3. Configure variants:
- Light mode color
- Dark mode color
- High Contrast Light (optional but recommended)
- High Contrast Dark (optional but recommended)
// Use custom color from asset catalog
Color("BrandAccent")
// Automatically uses correct variant---
Typography
System Fonts
San Francisco (SF): The system sans-serif font family.
- SF Pro: General use
- SF Compact: watchOS and space-constrained layouts
- SF Mono: Code and monospaced text
- SF Rounded: Softer, friendlier feel
- Weights: Ultralight, Thin, Light, Regular, Medium, Semibold, Bold, Heavy, Black
New York (NY): System serif font family for editorial content.
Both available as variable fonts with seamless weight transitions.
Font Weight Recommendations
From Apple HIG: "Avoid light font weights. Prefer Regular, Medium, Semibold, or Bold weights instead of Ultralight, Thin, or Light."
Why: Light weights have legibility issues, especially at small sizes, in bright lighting, or for users with visual impairments.
Hierarchy:
// Headers - Bold weight for prominence
Text("Header")
.font(.title.weight(.bold))
// Subheaders - Semibold
Text("Subheader")
.font(.title2.weight(.semibold))
// Body - Regular or Medium
Text("Body text")
.font(.body)
// Captions - Regular (never Light)
Text("Caption")
.font(.caption)Text Styles for Hierarchy
Use built-in text styles for automatic hierarchy and Dynamic Type support:
.font(.largeTitle) .font(.title) .font(.title2)
.font(.title3) .font(.headline) .font(.body)
.font(.callout) .font(.subheadline) .font(.footnote)
.font(.caption) .font(.caption2)All text styles scale automatically with Dynamic Type.
Dynamic Type Support
Requirement: Apps must support text scaling of at least 200% (iOS, iPadOS) or 140% (watchOS).
Implementation:
// ✅ CORRECT - Scales automatically
Text("Hello")
.font(.body)
// ❌ WRONG - Fixed size, doesn't scale
Text("Hello")
.font(.system(size: 17))Layout considerations:
- Reduce multicolumn layouts at larger sizes
- Minimize text truncation
- Use stacked layouts instead of inline at large sizes
- Maintain consistent information hierarchy regardless of size
Not all content scales equally: Prioritize what users actually care about. Secondary elements like tab titles shouldn't grow as much as primary content.
Custom Fonts
When using custom fonts:
- Ensure legibility at various distances and conditions
- Implement Dynamic Type support
- Respond to Bold Text accessibility setting
- Test at all text sizes
- Match system font behaviors for accessibility
If your custom font is thin: Increase size by ~2 points when pairing with uppercase Latin text.
Leading (Line Spacing)
Loose leading: Wide columns (easier to track to next line) Tight leading: Constrained height (avoid for 3+ lines)
// Adjust leading for specific layouts
Text("Long content...")
.lineSpacing(8) // Add space between lines---
Shapes & Geometry
Three Shape Types (iOS 26)
From WWDC25: "There's a quiet geometry to how our shapes fit together, driven by concentricity. By aligning radii and margins around a shared center, shapes can comfortably nest within each other."
1. Fixed Shapes
Constant corner radius regardless of size:
RoundedRectangle(cornerRadius: 12)Use when: You need a specific, unchanging corner radius.
2. Capsules
Radius is half the container's height:
Capsule()Use when: You want shapes that adapt to content while maintaining rounded ends. Perfect for buttons, pills, and controls.
Found throughout iOS 26: Sliders, switches, grouped table views, tab bars, navigation bars.
3. Concentric Shapes
Calculate radius by subtracting padding from parent's radius:
.containerRelativeShape(.roundedRectangle)Use when: Nesting shapes within containers to maintain visual harmony.
Concentricity Principle
Hardware ↔ Software harmony: Apple's hardware features consistent bezel curvature. The same precision now guides UI, with curvature, size, and proportion aligning to create unified rhythm between what you hold and what you see.
Example of concentricity:
Window (rounded corners)
├─ Sheet (concentric to window)
│ ├─ Card (concentric to sheet)
│ │ └─ Button (concentric to card)Platform-Specific Guidance
iOS:
- Capsules for buttons, switches, grouped lists
- Creates hierarchy and focus in touch-friendly layouts
macOS:
- Mini, Small, Medium controls → Rounded rectangles (dense layouts, inspector panels)
- Large, X-Large controls → Capsules (spacious areas, emphasis via Liquid Glass)
Optical Centering
To preserve optical balance, views are:
- Mathematically centered when it makes sense
- Subtly offset when optical weight requires it
Example: Asymmetric icons may need padding adjustments for optical centering rather than geometric centering.
---
Materials & Depth
Standard Materials
Materials allow background content to show through, creating visual depth and hierarchy.
Four Thickness Options
1. Ultra-thin — Minimal separation, content clearly visible 2. Thin — Lighter-weight interactions 3. Regular — Default, works well in most circumstances 4. Thick — Most separation from background
Choosing thickness:
- Content needs more contrast → thicker material
- Simpler content → thin/ultra-thin material
// Apply material
.background(.ultraThinMaterial)
.background(.thinMaterial)
.background(.regularMaterial)
.background(.thickMaterial)Vibrancy with Materials
Key principle: Use vibrant colors on top of materials for legibility. Solid colors can get muddy depending on background context. Vibrancy maintains contrast regardless of background.
// Vibrant text on material
VStack {
Text("Primary")
.foregroundStyle(.primary) // Vibrant
Text("Secondary")
.foregroundStyle(.secondary) // Vibrant
}
.background(.regularMaterial)Liquid Glass (iOS 26+)
Purpose: Creates a distinct functional layer for controls and navigation, floating above content.
Two variants:
1. Regular Liquid Glass
- Default, use in 95% of cases
- Full visual and adaptive effects
- Provides legibility regardless of context
- Works over any background
2. Clear Liquid Glass
- Highly translucent
- No adaptive behaviors
- Only use for components over visually rich backgrounds (photos, videos)
- Requires dimming layer for legibility
Modals & Sheets (iOS 26+): Sheets, alerts, and popovers automatically adopt Liquid Glass with Xcode 26 — remove custom .presentationBackground() or UIBlurEffect backgrounds. System handles material, concentric corner radius, and morphing transitions. Use elevated semantic colors for modal content backgrounds, not Liquid Glass on the sheet body.
Sheet button placement (HIG, updated 2026-03-24):
- Always pair a confirmation button (Done) with Cancel — or with Back when the sheet is mid-flow. Never ship a solo Done; it implies completion is the only exit.
- Don't show Cancel, Done, and Back together. Choose the pair the step needs.
- iOS / iPadOS: Cancel on the leading edge of the top toolbar, Done on the trailing edge. In SwiftUI,
.cancellationActionand.confirmationActionproduce these placements with the correct emphasis — seeaxiom-swiftui (skills/toolbars.md)Pattern 2. - watchOS: prefer SF Symbols for sheet action labels.
Cross-reference: For full Liquid Glass implementation patterns (sheets, alerts, popovers, morphing transitions), see axiom-design (skills/liquid-glass-ref.md). For decision trees, see axiom-design (skills/liquid-glass.md).
---
Layout Principles
Visual Hierarchy
Place items to convey their relative importance:
- Important content → top and leading side
- Secondary content → below or trailing
- Tertiary content → separate views or progressive disclosure
From Apple HIG: "Make essential information easy to find by giving it sufficient space and avoid obscuring it with nonessential details."
Grouping & Organization
Group related items using:
- Negative space (whitespace)
- Colors and materials
- Separator lines
Ensure content and controls remain clearly distinct through Liquid Glass material and scroll edge effects.
Content Extension to Edges
"Extend content to fill the screen or window" with backgrounds and artwork reaching display edges.
Background extension views: Use when content doesn't naturally span the full window.
// Content extends to edges
VStack {
FullWidthImage()
.ignoresSafeArea() // Extends to screen edges
}Safe Areas & Layout Guides
Safe Areas: Rectangular regions unobstructed by:
- Status bar
- Navigation bar
- Tab bar
- Toolbar
- Device features (Dynamic Island, notch, home indicator)
Layout Guides: Define rectangular regions for positioning and spacing content with:
- Predefined margins
- Text width optimization
- Reading width constraints
Key principle: "Respect key display and system features in each platform."
// Respect safe areas
VStack {
Text("Content")
}
.safeAreaInset(edge: .bottom) {
BottomBar()
}Align Components
"Align components with one another to make them easier to scan."
Grid alignment:
- Text baselines align
- Controls align on common grid
- Spacing is consistent and rhythmic
Adaptability Requirements
Design layouts that:
- "Adapt gracefully to context changes while remaining recognizably consistent"
- Support Dynamic Type text-size changes
- Work across multiple devices, orientations, and localizations
- Account for different screen sizes, resolutions, and system features
---
Accessibility
Vision Accessibility
Text & Legibility
Requirements:
- Support text enlargement of at least 200% (140% for watchOS)
- Implement Dynamic Type for systemwide text adjustment
- Use font weights that enhance readability (avoid Light weights with custom fonts)
Color Contrast
WCAG Level AA standards:
- Normal text (14pt+): 4.5:1 minimum
- Small text (<14pt): 7:1 recommended
- Large text (18pt+ regular, 14pt+ bold): 3:1 acceptable
Implementation:
// ✅ Use semantic colors (automatic contrast)
Text("Label").foregroundStyle(.primary)
// ❌ Custom colors may fail contrast
Text("Label").foregroundStyle(.gray) // Check with calculatorHigh contrast mode: Provide higher contrast color schemes when "Increase Contrast" accessibility setting is enabled.
Test in both Light and Dark modes.
Color Considerations
Critical: "Convey information with more than color alone" to support colorblind users.
Solutions:
- Use distinct shapes or icons alongside color
- Add text labels
- Employ system-defined colors with accessible variants
- Test with Color Blindness simulators
Example:
// ❌ Only color indicates status
Circle().fill(isActive ? .green : .red)
// ✅ Shape + color
HStack {
Image(systemName: isActive ? "checkmark.circle.fill" : "xmark.circle.fill")
Text(isActive ? "Active" : "Inactive")
}
.foregroundStyle(isActive ? .green : .red)Screen Readers
Describe interface and content for VoiceOver accessibility:
Button {
share()
} label: {
Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel("Share")Hearing Accessibility
Media Alternatives
For video/audio content, provide:
- Captions for dialogue
- Subtitles
- Audio descriptions for visual-only information
- Transcripts for longer-form media
Audio Cues
Pair audio signals with:
- Haptic feedback
- Visual indicators
Mobility Accessibility
Touch targets:
- Minimum: 44x44 points
- Spacing: 12-24 points padding around controls
Gestures:
- Use simple gestures
- Offer alternatives (buttons alongside gestures)
- Support Voice Control
- Enable keyboard navigation
Assistive technologies:
- VoiceOver
- Switch Control
- Full Keyboard Access
Cognitive Accessibility
Interaction Design
- "Keep actions simple and intuitive"
- Avoid time-based auto-dismissing views
- Prevent autoplay of audio/video without controls
Motion & Visual Effects
Respect "Reduce Motion":
- Minimize animations
- Avoid excessive flashing lights
- Support "Dim Flashing Lights"
- Reduce bounce effects
- Minimize z-axis depth changes
// Check Reduce Motion setting
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
content
.animation(reduceMotion ? nil : .spring(), value: isExpanded)
}Game Accommodations
Offer adjustable difficulty levels.
visionOS Specific
Prioritize comfort:
- Maintain horizontal layouts
- Reduce animation speed
- Avoid head-anchored content (prevents assistive technology use)
---
Motion & Animation
Core Principles
Purposeful Animation: "Add motion purposefully, supporting the experience without overshadowing it."
Avoid gratuitous animations that distract or cause discomfort. Motion should enhance rather than dominate the interface.
Accessibility First
Make motion optional. Supplement visual feedback with haptics and audio to communicate important information, ensuring all users can understand your interface regardless of motion preferences.
Best Practices for Feedback
Realistic Motion
Design animations aligned with user expectations and gestures. Feedback should be:
- "Brief and precise"
- Lightweight
- Effectively conveying information without distraction
Frequency Considerations
Avoid animating frequent UI interactions. Standard system elements already include subtle animations, so custom elements shouldn't add unnecessary motion to common actions.
User Control
"Let people cancel motion" by not forcing them to wait for animations to complete before proceeding, especially for repeated interactions.
// ✅ Allow immediate tap, don't block on animation
Button("Next") {
withAnimation(.easeOut(duration: 0.2)) {
showNext = true
}
}
// User can tap again immediately, not forced to waitPlatform-Specific Guidance
visionOS
- Avoid motion at peripheral vision edges — causes discomfort
- Use fades when relocating objects rather than visible movement
- Maintain stationary frames of reference
- Avoid sustained oscillations (especially at 0.2 Hz frequency)
- Prevent virtual world rotation (disrupts stability)
watchOS
SwiftUI provides animation capabilities; WatchKit offers WKInterfaceImage for layout animations and sequences.
---
Icons & Symbols
SF Symbols
6,900+ vector symbols that match San Francisco font, scale with Dynamic Type, and adapt to Bold Text and Dark Mode automatically. Nine weights, three scales, four rendering modes, and 12+ animation effects.
For comprehensive coverage of rendering modes (Monochrome, Hierarchical, Palette, Multicolor), symbol effects (Bounce, Pulse, Wiggle, Draw On/Off), and custom symbol authoring, seeaxiom-design (skills/sf-symbols.md)(decision trees) andaxiom-design (skills/sf-symbols-ref.md)(complete API).
Custom Interface Icons
Design principles: Recognizable, simplified designs with familiar visual metaphors. Maintain uniform size, detail level, stroke thickness, and perspective. Match icon weight with adjacent text. Adjust padding for optical centering when visual weight is asymmetric.
Format: Use PDF or SVG for automatic scaling. System components handle selected states automatically.
When to Use Icons vs Text
From WWDC25: "A pencil might suggest annotate, and a checkmark can look like confirm—making actions like Select or Edit easy to misread. When there's no clear shorthand, a text label is always the better choice."
Use icons when:
- Symbol has clear, universal meaning (share, trash, settings)
- Space is constrained
- Icon aids quick scanning
Use text when:
- Action has no clear symbol
- Multiple similar actions exist
- Clarity is more important than space
Accessibility
Always provide alternative text labels enabling VoiceOver descriptions:
Image(systemName: "star.fill")
.accessibilityLabel("Favorite")---
Gestures & Input
Core Gesture Design Principles
Consistency and Familiarity: "People expect most gestures to work the same regardless of their current context." Standard gestures like tap, swipe, and drag should perform their expected functions across platforms.
Responsive Feedback: "Handle gestures as responsively as possible" and provide immediate feedback during gesture performance so users can predict outcomes.
Standard Gestures
Basic gestures supported across all platforms (though precise movements vary by device):
- Tap
- Swipe
- Drag
- Pinch
- Rotate (iOS/iPadOS)
- Long press
Touch Target Requirements
Minimum touch target sizes:
| Platform | Minimum Size | Spacing |
|---|---|---|
| iOS/iPadOS | 44x44 points | 12-24pt padding |
| macOS | Varies by control | System spacing |
| watchOS | System controls | Optimized for small screen |
| tvOS | Large (focus model) | 60pt+ spacing |
// ✅ Adequate touch target
Button("Tap") { }
.frame(minWidth: 44, minHeight: 44)
// ❌ Too small
Button("Tap") { }
.frame(width: 20, height: 20) // Fails accessibilityCustom Gesture Guidelines
Custom gestures should only be implemented when necessary and must be:
- Discoverable — Users can find them
- Straightforward to perform — Easy to execute
- Distinct from other gestures — No conflicts
- Never the only method — Provide alternatives for important actions
Warning: Don't replace standard gestures with custom ones. Shortcuts should supplement, not replace, familiar interactions.
Accessibility
Critical: "Give people more than one way to interact with your app." Never assume users can perform specific gestures.
Provide alternatives:
- Voice control
- Keyboard navigation
- Button alternatives to gestures
// ✅ Swipe action + button alternative
.swipeActions {
Button("Delete", role: .destructive) {
delete()
}
}
.contextMenu {
Button("Delete", role: .destructive) {
delete()
}
}---
Launch & Onboarding
Launch Screens
Mandatory for: iOS, iPadOS, tvOS Not required for: macOS, visionOS, watchOS
Design principle: "Design a launch screen that's nearly identical to the first screen of your app or game" to avoid jarring visual transitions.
Best Practices
Minimize branding:
- Avoid logos
- No splash screens
- No artistic flourishes
- Purpose: Enhance perception of quick startup, not showcase brand
No text:
- Launch screen content cannot be localized
- Avoid text entirely
Match appearance:
- Respect device orientation
- Adapt to light/dark mode
// Launch screen matches first screen
// Transitions smoothly without flashOnboarding
Onboarding is a separate experience that follows the launch phase. Provides "a high-level view of your app or game" and can include a splash screen if needed.
When to use: Only when you have meaningful context to communicate to new users.
What onboarding can include:
- Branding and splash screens
- Educational content
- Permission requests
- Account setup
Timeline: 1. Launch — System displays launch screen, transitions to first screen 2. Onboarding (optional) — Can include branding and education 3. Continued use — "Restore the previous state when your app restarts so people can continue where they left off"
---
Platform-Specific Guidance
iOS
Tab Bar Guidelines:
- Maximum 5 tabs on iPhone (6th+ go in "More" automatically)
- Every tab must have icon AND text label — icon-only violates HIG
- Always visible — don't hide during navigation within a tab
- Tab order reflects usage frequency (most-used on left)
- Maintain tab state: preserve scroll position and navigation state when switching
- iOS 26: Liquid Glass automatic — don't add custom blur/material backgrounds
- iPad: tab bar → sidebar in landscape; use
TabViewwithTabfor adaptation
Navigation Bar Guidelines:
- Always use system back button (chevron) — don't replace with custom "X"
- Title describes current view content, not app name
- Large titles (
prefersLargeTitles) for top-level views only; inline for pushed views - 1-3 toolbar actions max; use
...menu for additional - iOS 26: Liquid Glass with toolbar morphing between views
System integration: Widgets, Home Screen quick actions, Spotlight, Shortcuts, Activity views
iPadOS
Extends iOS with larger display, sidebar navigation, split view, pointer/trackpad, arbitrary windows (iOS 26+). Don't just scale iOS layouts — leverage sidebars and split views.
macOS
Pointer-first, keyboard-centric. Dense layouts, smaller controls than iOS. Multiple windows, menu bar, contextual menus, keyboard shortcuts essential. Controls: Mini/Small/Medium → rounded rectangles, Large/X-Large → capsules.
watchOS
Very small display — glanceable, minimal interaction. Full-bleed content, minimal padding, Digital Crown interactions, complications for watch faces. Always-on display consideration.
Adapting from iPad/iOS: Replace sidebars with page-based flow. Convert swipe/pinch to Digital Crown rotation. Use opacity/spacing for hierarchy (no materials/Liquid Glass). Complications replace dashboards. @State/@Environment reuse well; view hierarchy must be rewritten.
tvOS
10-foot viewing distance, focus-based navigation, gestural remote. Large touch targets, prominent focus states, limited text input, directional navigation.
Focus Engine: tvOS uses a UIKit Focus Engine for hardware navigation that coexists with SwiftUI's @FocusState. The Focus Engine is the ultimate authority — @FocusState assignments are ignored if the Focus Engine considers a view unfocusable. Use UIFocusGuide to bridge navigation gaps between isolated views.
TVUIKit: tvOS-exclusive components — TVPosterView (parallax focus effects), TVDigitEntryViewController (PIN entry). No SwiftUI equivalents exist; bridge via UIViewRepresentable.
Text input: Standard text fields trigger a fullscreen system keyboard. For better UX, use the shadow input pattern (Button UI + hidden CocoaTextField). See axiom-swift (skills/tvos.md) for implementation details.
Storage: No persistent local storage. All local files are cache that the system deletes. See axiom-swift (skills/tvos.md) for data strategy.
visionOS
Spatial computing with glass materials, 3D layouts, depth. Comfortable viewing depth, avoid head-anchored content, center content in field of view.
---
Inclusive Design
Language & Communication
Welcoming language requirements:
- Use plain, direct, and respectful tone
- Don't suggest exclusivity based on education level
- Address people directly with "you/your" rather than "the user"
- Define specialized or technical terms when necessary
- Replace culture-specific expressions with plain alternatives
Avoid phrases with oppressive origins (e.g., "peanut gallery").
Exercise caution with humor — it's subjective and difficult to translate across cultures.
Visual Representation
Portraying human diversity:
- Feature people demonstrating range of racial backgrounds, body types, ages, physical capabilities
- Avoid stereotypical representations in occupations and behaviors
Avoiding assumptions:
- Don't assume narrow definitions of family structures
- Don't assume universal experiences
- Replace culture-specific security questions with more universal experiences
Gender Identity & Pronouns
Best practices:
- Avoid unnecessary gender references in copy
- Provide inclusive options: "nonbinary," "self-identify," "decline to state"
- Use nongendered imagery
- Allow customization of avatars and characters
Accessibility & Disability
Recognize:
- Disabilities exist on spectrums
- Temporary/situational disabilities affect everyone
Include:
- People with disabilities in diversity representations
- Adopt people-first approach in writing ("person with disability" vs "disabled person")
Localization & Global Considerations
Prepare software for:
- Internationalization
- Translation into multiple languages
Cultural color awareness:
- Colors carry culture-specific meanings
- White represents death in some cultures, purity in others
- Red signifies danger in some cultures, positive meanings elsewhere
Use plain language and avoid stereotypes to facilitate smoother localization.
---
Branding
Core Principles
Voice & Tone: Maintain consistent brand personality through written communication.
Visual Elements:
- Consider accent color for UI components
- Custom font if strongly associated with brand (but system fonts work better for body copy due to legibility)
Key Restraint Guidelines
Most critical guidance — restraint:
Defer to content: "Using screen space for an element that does nothing but display a brand asset can mean there's less room for the content people care about."
Logo minimalism: "Resist the temptation to display your logo throughout your app or game unless it's essential for providing context."
Familiar patterns: Maintain standard UI behaviors and component placement even with stylized designs to keep interfaces approachable.
Launch screen caution: Avoid using launch screens for branding since they disappear too quickly; consider onboarding screens instead for brand integration.
Appropriate Branding
Do:
- Use your brand's accent color as app tint color
- Include branding in onboarding (not launch screen)
- Use brand voice in copy
- Feature brand in content, not chrome
Don't:
- Display logo in navigation bar
- Override system backgrounds with brand colors
- Add splash screens
- Make branding compete with content
Legal Consideration
Apple trademarks cannot appear in your app name or images—consult Apple's official trademark guidelines.
---
Troubleshooting Common HIG Issues
Color Contrast Failures
Symptom: App Store rejection for accessibility violations, or colors don't meet WCAG standards.
Diagnosis: Test with Accessibility Inspector, contrast calculators, both Light/Dark modes, and Increase Contrast enabled. See Accessibility > Vision section above for contrast ratio requirements.
Solution:
// ❌ Custom gray may fail contrast
Text("Label").foregroundStyle(.gray)
// ✅ Semantic colors (automatic compliance)
Text("Label").foregroundStyle(.secondary)
// ✅ Verified custom color (~8:1 on white, WCAG AAA)
Text("Label").foregroundStyle(Color(red: 0.25, green: 0.25, blue: 0.25))Touch Targets Too Small
Symptom: Users report difficult tapping, App Store accessibility rejection.
Diagnosis:
// Check button size
Button("Tap") { }
.frame(width: 30, height: 30) // ❌ Too smallSolution:
// ✅ Expand touch target to minimum 44x44
Button("Tap") { }
.frame(minWidth: 44, minHeight: 44)
// ✅ Alternative: Add padding
Button("Tap") { }
.padding() // System adds appropriate paddingDark Mode Issues
Symptom: Colors look wrong in Dark Mode, insufficient contrast.
Diagnosis:
- Hardcoded colors that don't adapt
- Custom colors without dark variants
- Not testing in both appearance modes
Solution:
// ❌ PROBLEM: Hardcoded white text
Text("Label").foregroundStyle(.white)
// Invisible in Light Mode
// ✅ SOLUTION: Semantic color
Text("Label").foregroundStyle(.primary)
// Black in Light, white in Dark
// ✅ ALTERNATIVE: Asset catalog color with variants
Text("Label").foregroundStyle(Color("BrandText"))
// Define in Assets.xcassets with Light/Dark variantsLight Font Weight Legibility
Symptom: Text hard to read, especially at small sizes or in bright lighting.
Diagnosis:
Text("Headline")
.font(.system(size: 17, weight: .ultralight)) // ❌ Too lightSolution:
// ✅ Use Regular minimum
Text("Headline")
.font(.system(size: 17, weight: .regular))
// ✅ Better: Use system text styles
Text("Headline")
.font(.headline) // Automatically uses appropriate weightDynamic Type Not Working
Symptom: Text doesn't scale when user changes text size in Settings.
// ❌ Fixed size doesn't scale
Text("Label").font(.system(size: 17))
// ✅ Text styles scale automatically
Text("Label").font(.body)
// ✅ Custom font with scaling
Text("Label").font(.custom("CustomFont", size: 17, relativeTo: .body))Reduce Motion Not Respected
Symptom: Users with motion sensitivity experience discomfort.
Diagnosis:
- Animations always play regardless of setting
- No alternative for motion-sensitive users
Solution:
// ✅ Check Reduce Motion setting
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
content
.animation(reduceMotion ? nil : .spring(), value: isExpanded)
}
// ✅ Alternative: Simpler animation
.animation(reduceMotion ? .linear(duration: 0.1) : .spring(), value: isExpanded)VoiceOver Labels Missing
Symptom: VoiceOver announces unhelpful information like "Button" instead of action.
Diagnosis:
// ❌ Image button without label
Button {
share()
} label: {
Image(systemName: "square.and.arrow.up")
}
// VoiceOver says: "Button"Solution:
// ✅ Add accessibility label
Button {
share()
} label: {
Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel("Share")
// VoiceOver says: "Share, Button"Information Only Conveyed by Color
Symptom: Colorblind users can't distinguish status.
Diagnosis:
// ❌ Only color indicates state
Circle()
.fill(isComplete ? .green : .red)Solution:
// ✅ Use shape + color + text
HStack {
Image(systemName: isComplete ? "checkmark.circle.fill" : "xmark.circle.fill")
Text(isComplete ? "Complete" : "Incomplete")
}
.foregroundStyle(isComplete ? .green : .red)Launch Screen Branding Rejection
Symptom: App Store rejects launch screen with logo or text.
Diagnosis:
- Launch screen contains branding elements
- Launch screen has text that can't be localized
Solution:
// ❌ Launch screen with logo (rejected)
// Launch.storyboard contains app logo
// ✅ Launch screen matches first screen (approved)
// Launch.storyboard shows same background/layout as first screen
// No text, no logos, minimal branding
// Move branding to onboarding screen insteadCustom Appearance Toggle Issues
Symptom: Users confused by app-specific dark mode setting, double settings.
Diagnosis:
- App has its own Light/Dark toggle
- Conflicts with system Settings → Display & Brightness
Solution:
// ❌ App-specific appearance toggle
.preferredColorScheme(userPreference == .dark ? .dark : .light)
// ✅ Respect system preference
// Remove custom toggle, use system preference
// Let iOS Settings control appearance---
Resources
WWDC: 356, 2019-808
Docs: /design/human-interface-guidelines, /design/human-interface-guidelines/color, /design/human-interface-guidelines/dark-mode, /design/human-interface-guidelines/materials, /design/human-interface-guidelines/typography, /design/human-interface-guidelines/layout, /design/human-interface-guidelines/accessibility, /design/human-interface-guidelines/icons, /design/human-interface-guidelines/apple-pay, /design/human-interface-guidelines/wallet, /design/human-interface-guidelines/tap-to-pay-on-iphone
Skills: axiom-design (skills/hig.md), axiom-design (skills/liquid-glass.md), axiom-design (skills/liquid-glass-ref.md), axiom-swiftui, axiom-accessibility, axiom-swift (skills/tvos.md), axiom-payments
---
Last Updated: Based on Apple HIG (2024-2025), WWDC25-356, WWDC19-808 Skill Type: Reference (Comprehensive guide with code examples)
Apple Human Interface Guidelines — Quick Reference
When to Use This Skill
Use when:
- Making visual design decisions (colors, backgrounds, typography)
- Reviewing UI for HIG compliance
- Answering "Should I use a dark background?"
- Choosing between design options
- Defending design decisions to stakeholders
- Quick lookups for common design questions
Related Skills
- Use
axiom-design (skills/hig-ref.md)for comprehensive details and code examples - Use
axiom-design (skills/liquid-glass.md)for iOS 26 material design implementation and version-conditional design (supporting both pre-Liquid Glass and Liquid Glass in the same app) - Use
axiom-design (skills/liquid-glass-ref.md)for iOS 26 app-wide adoption guide with backward compatibility strategy - Use
axiom-accessibilityfor accessibility troubleshooting
Version-Conditional Design
When supporting both iOS 25 (pre-Liquid Glass) and iOS 26+, see axiom-design (skills/liquid-glass.md) for the adoption strategy — it covers when to use #available(iOS 26, *), how to degrade gracefully, and which system components adopt Liquid Glass automatically vs which need explicit opt-in.
---
Quick Decision Trees
Background Color Decision
Is your app media-focused (photos, videos, music)?
├─ Yes → Consider permanent dark appearance
│ WHY: "Lets UI recede, helps people focus on media" (Apple HIG)
│ EXAMPLES: Apple Music, Photos, Clock apps use dark
│ CODE: .preferredColorScheme(.dark) on root view
│
└─ No → Use system backgrounds (respect user preference)
CODE: systemBackground (adapts to light/dark automatically)
GROUPED: systemGroupedBackground for iOS Settings-style listsApple's guidance: "In rare cases, consider using only a dark appearance in the interface. For example, it can make sense for an app that enables immersive media viewing to use a permanently dark appearance."
Color Selection Decision
Do you need a specific color value?
├─ No → Use semantic colors
│ label, secondaryLabel, tertiaryLabel, quaternaryLabel
│ systemBackground, secondarySystemBackground, tertiarySystemBackground
│ WHY: Automatically adapts to light/dark/high contrast
│
└─ Yes → Create Color Set in asset catalog
1. Open Assets.xcassets
2. Add Color Set
3. Configure variants:
├─ Light mode color
├─ Dark mode color
└─ High contrast (optional but recommended)Key principle: "Use semantic color names like labelColor that automatically adjust to the current interface style."
Font Weight Decision
Which font weight should I use?
├─ ❌ AVOID: Ultralight, Thin, Light
│ WHY: Legibility issues, especially at small sizes
│
├─ ✅ PREFER: Regular, Medium, Semibold, Bold
│ WHY: Maintains legibility across sizes and conditions
│
└─ Headers: Semibold or Bold for hierarchy
Body: Regular or MediumApple's guidance: "Avoid light font weights. Prefer Regular, Medium, Semibold, or Bold weights instead of Ultralight, Thin, or Light."
---
Core Principles Checklist
Before Shipping Any UI
Verify every screen passes these checks:
Appearance
- [ ] Works in Light Mode
- [ ] Works in Dark Mode
- [ ] Passes with Increased Contrast enabled
- [ ] Passes with Reduce Transparency enabled
Typography
- [ ] Supports Dynamic Type (text scales to 200%)
- [ ] No light font weights (Regular minimum)
- [ ] Hierarchy clear at all text sizes
- [ ] No truncation at large text sizes
Accessibility
- [ ] Contrast ratio ≥ 4.5:1 minimum
- [ ] Contrast ratio ≥ 7:1 for small text (recommended)
- [ ] Touch targets ≥ 44x44 points
- [ ] Information conveyed by more than color alone
- [ ] VoiceOver labels for all interactive elements
Motion
- [ ] Respects Reduce Motion setting
- [ ] Animations can be canceled/skipped
- [ ] No auto-playing video without controls
Localization
- [ ] No hardcoded strings in images
- [ ] Right-to-left language support
- [ ] Proper text directionality
---
Common Design Questions
Q: Should my app have a dark background?
A: Only for media-focused apps (photos, videos, music) where content should be the hero. Use system backgrounds for everything else.
Apple's own apps:
| App | Background | Reason |
|---|---|---|
| Music | Dark | Album art is focus |
| Photos | Dark | Images are hero |
| Clock | Dark | Nighttime use |
| Notes | System | Document editing |
| Settings | System | Utilitarian |
Code:
// ❌ WRONG - Don't override unless media-focused
.background(Color.black)
// ✅ CORRECT - Let system decide
.background(Color(.systemBackground))Q: What's the right background color?
A: Use systemBackground which adapts to light/dark automatically. For grouped content (like iOS Settings), use systemGroupedBackground.
Color hierarchy:
- Primary:
systemBackground- Main background - Secondary:
secondarySystemBackground- Grouping elements - Tertiary:
tertiarySystemBackground- Grouping within secondary
// ✅ Standard list
List { }
.background(Color(.systemBackground))
// ✅ Grouped list (Settings style)
List { }
.listStyle(.grouped)
.background(Color(.systemGroupedBackground))Q: How do I ensure legibility?
A: Use semantic label colors, maintain 4.5:1 contrast, avoid light font weights.
Label hierarchy:
// Most prominent
Text("Title").foregroundStyle(.primary)
// Subtitles
Text("Subtitle").foregroundStyle(.secondary)
// Tertiary information
Text("Detail").foregroundStyle(.tertiary)
// Disabled text
Text("Disabled").foregroundStyle(.quaternary)Q: Should I use SF Symbols or custom icons?
A: SF Symbols unless you need brand-specific imagery. They scale with Dynamic Type and adapt to appearance automatically.
Benefits of SF Symbols:
- 5,000+ symbols included (SF Symbols 5)
- Automatic light/dark adaptation
- Scale with Dynamic Type
- Become bolder with Bold Text accessibility
- Nine weights matching San Francisco font
When to use custom:
- Brand-specific imagery
- App-specific concepts not in SF Symbols
- Unique visual style requirement
Q: Light/Dark Mode or user choice?
A: Always support both. Never create app-specific appearance settings.
Apple's guidance: "Avoid creating app-specific appearance settings. Users expect apps to honor their systemwide Dark Mode choice. An app-specific appearance mode option creates more work for people because they have to adjust more than one setting to get the appearance they want."
Q: What contrast ratio do I need?
A: 4.5:1 minimum for normal text, 7:1 recommended for small text.
WCAG Contrast Standards:
- AA (required): 4.5:1 for normal text, 3:1 for large text (18pt+/14pt+ bold)
- AAA (enhanced): 7:1 for normal text, 4.5:1 for large text
- Apple guidance: Use semantic colors which automatically meet AA requirements
Testing: Use online contrast calculators or Xcode's Accessibility Inspector.
Q: What's the minimum touch target size?
A: 44x44 points on iOS/iPadOS, with spacing between targets.
Platform-specific:
- iOS/iPadOS: 44x44 points minimum
- macOS: 20x20 points minimum; larger for primary actions
- watchOS: Use system controls (optimized for small screen)
- tvOS: 60+ point spacing for focus clarity
---
Design Review Checklist
When Reviewing Any Design
Use this checklist for design reviews, App Store submissions, or stakeholder presentations:
Content-First Design
- [ ] Does UI defer to content? (Not competing for attention)
- [ ] Is branding restrained? (No logo on every screen)
- [ ] Are backgrounds content-appropriate? (Media apps dark, others system)
Platform Consistency
- [ ] Does it feel native to iOS/iPad/Mac?
- [ ] Uses system colors and fonts?
- [ ] Standard gestures work as expected?
- [ ] Navigation patterns familiar?
Accessibility Compliance
- [ ] All contrast ratios meet requirements?
- [ ] All touch targets ≥ 44x44 points?
- [ ] Information conveyed beyond color?
- [ ] VoiceOver labels complete?
- [ ] Dynamic Type supported?
Light & Dark Modes
- [ ] Works in both appearance modes?
- [ ] Colors adapt automatically?
- [ ] No hardcoded color values?
- [ ] Increased Contrast tested?
Localization-Ready
- [ ] No hardcoded strings in images?
- [ ] RTL language support?
- [ ] Text doesn't truncate?
- [ ] Layouts adapt to text size?
---
Design Review Pressure: Defending HIG Decisions
The Problem
In design reviews, you'll hear:
- "Let's add our logo to every screen for brand consistency"
- "Use light font weights—they look more elegant"
- "Make a custom appearance toggle—some users prefer dark"
- "This screen needs a splash screen for our brand"
These violate HIG. Here's how to push back professionally.
Red Flags — Requests That Violate HIG
If you hear ANY of these, reference this skill:
- ❌ "Add logo to navigation bar" — Wastes space, distracts from content
- ❌ "Use Ultralight font" — Legibility issues, fails accessibility
- ❌ "Custom dark mode toggle" — Creates more work for users, ignores system preference
- ❌ "Splash screen for branding" — Launch screens can't include branding
- ❌ "Custom brand color for all text" — May fail contrast requirements
How to Push Back Professionally
Step 1: Show the HIG Guidance
"I want to make this change, but let me show you Apple's guidance:
[Show the relevant HIG section from this skill or hig-ref]
Apple explicitly recommends against this because..."Step 2: Demonstrate the Risk
For contrast issues:
- Show the design at 4.5:1 contrast (passing)
- Show their proposal (failing)
- Explain App Store rejection risk
For appearance toggles:
- Show iOS Settings → Display & Brightness
- Explain users already have this control
- Demonstrate confusion of two separate settings
Step 3: Offer Compromise
"I understand the brand concern. Here are HIG-compliant alternatives:
1. Use your brand color as the app's tint color
2. Feature branding in onboarding (not launch screen)
3. Use your accent color for primary actions
4. Include subtle branding in content, not chrome"Step 4: Document the Decision
If overruled:
Slack message to PM + designer:
"Design review decided to [violate HIG guidance].
Important risks to monitor:
- App Store rejection (HIG violations)
- Accessibility issues (users with visual impairments)
- User complaints (departure from platform norms)
I'm flagging this proactively. If we see issues after launch,
we'll need an expedited follow-up."When to Accept the Design Decision
Sometimes designers have valid reasons to override HIG. Accept if:
- [ ] They understand the HIG guidance
- [ ] They're willing to accept rejection/accessibility risks
- [ ] You document the decision in writing
- [ ] They commit to monitoring post-launch feedback
---
Three Core HIG Principles
Every design decision should support these principles:
1. Clarity
Definition: Content should be paramount, interface elements should defer to content.
In practice:
- White space is your friend
- Every element has a purpose
- Remove anything that doesn't serve the user
- Users should know what they can do without instructions
2. Consistency
Definition: Use standard UI elements and familiar patterns.
In practice:
- Standard gestures work as expected
- Navigation follows platform conventions
- Colors and fonts use system values
- Familiar components in familiar locations
3. Deference
Definition: UI shouldn't compete with content for attention.
In practice:
- Subtle backgrounds, not bold
- Navigation recedes when not needed
- Content is the hero
- Branding is restrained
From HIG: "Deference makes an app beautiful by ensuring the content stands out while the surrounding visual elements do not compete with it."
---
Platform-Specific Quick Tips
iOS
- Portrait-first design
- One-handed reachability
- Bottom tab bar for primary navigation
- Swipe back gesture
iPadOS
- Sidebar-adaptable layouts
- Split view support
- Pointer interactions
- Arbitrary window sizing (iOS 26+)
macOS
- Menu bar for commands
- Dense layouts acceptable
- Pointer-first interactions
- Window chrome and controls
watchOS
- Glanceable interfaces
- Full-bleed content
- Minimal padding
- Digital Crown interactions
tvOS
- Focus-based navigation
- 10-foot viewing distance
- Large touch targets
- Gestural remote
visionOS
- Spatial layout
- Glass materials
- Comfortable viewing depth
- Avoid head-anchored content
---
Payments
Apple Pay, Wallet, and Tap to Pay each have their own HIG with rules App Review enforces. The discipline lives in axiom-payments:
- Apple Pay button + Apple Pay Mark + payment-sheet UX →
axiom-payments/skills/apple-pay.md§ "Apple Pay Mark vs Apple Pay Button" +apple-pay-web.md§ "Acceptable Use Guidelines" (parity rule) - Wallet pass design (image specs, Apple Watch layout, semantic tags, poster event ticket iOS 18+) →
axiom-payments/skills/wallet-passes.md§ "iOS 18 Poster Event Ticket Migration" +wallet-passes-ref.mdimage table - Tap to Pay on iPhone (button label, T&C flow, progress indicator, generic labels for non-payment uses) →
axiom-payments/skills/tap-to-pay.md§ "Checkout UX"
Resources
WWDC: 356, 2019-808
Docs: /design/human-interface-guidelines, /design/human-interface-guidelines/color, /design/human-interface-guidelines/dark-mode, /design/human-interface-guidelines/typography, /design/human-interface-guidelines/apple-pay, /design/human-interface-guidelines/wallet, /design/human-interface-guidelines/tap-to-pay-on-iphone
Skills: axiom-design (skills/hig-ref.md), axiom-design (skills/liquid-glass.md), axiom-design (skills/liquid-glass-ref.md), axiom-accessibility, axiom-payments
---
Last Updated: Based on Apple HIG (2024-2025), WWDC25-356, WWDC19-808 Skill Type: Discipline (Quick decisions, checklists, pressure scenarios)
Typography Reference
Complete reference for typography on Apple platforms including San Francisco font system, text styles, Dynamic Type, tracking, leading, and internationalization through iOS 26.
San Francisco Font System
Font Families
SF Pro and SF Pro Rounded (iOS, iPadOS, macOS, tvOS)
- Main system fonts for most UI elements
- Rounded variant for friendly, approachable interfaces (e.g., Reminders app)
SF Compact and SF Compact Rounded (watchOS, narrow columns)
- Optimized for constrained spaces and small sizes
- watchOS default system font
SF Mono (Code environments, monospaced text)
- Monospaced font for code editors and technical content
- Consistent character widths for alignment
New York (Serif system font)
- Serif alternative for editorial content
- Works with text styles just like SF Pro
Variable Font Axes
Weight Axis (9 weights)
- Ultralight, Thin, Light, Regular, Medium, Semibold, Bold, Heavy, Black
- Continuous weight spectrum via variable fonts
- Avoid light weights at small sizes (legibility issues)
Width Axis (WWDC 2022)
- Condensed — narrowest width
- Compressed — narrow width
- Regular — standard width (default)
- Expanded — wide width
Access via:
// iOS/macOS
let descriptor = UIFontDescriptor(fontAttributes: [
.family: "SF Pro",
kCTFontWidthTrait: 1.0 // 1.0 = Expanded
])SF Arabic (WWDC 2022)
- Matches SF Pro design language for Arabic text
- Proper right-to-left support
Optical Sizes
Variable fonts automatically adjust optical size based on point size:
- Text variant (< 20pt) — more spacing, sturdier strokes
- Display variant (≥ 20pt) — tighter spacing, refined details
- Smooth transition (17-28pt) with variable SF Pro
From WWDC 2020:
"TextKit 2 abstracts away glyph handling to provide a consistent experience for international text."
Text Styles & Dynamic Type
System Text Styles
| Text Style | Default Size (iOS) | Use Case |
|---|---|---|
.largeTitle | 34pt | Primary page headings |
.title | 28pt | Secondary headings |
.title2 | 22pt | Tertiary headings |
.title3 | 20pt | Quaternary headings |
.headline | 17pt (Semibold) | Emphasized body text |
.body | 17pt | Primary body text |
.callout | 16pt | Secondary body text |
.subheadline | 15pt | Tertiary body text |
.footnote | 13pt | Footnotes, captions |
.caption | 12pt | Small annotations |
.caption2 | 11pt | Smallest annotations |
Font Size Guidance
- Avoid `.caption2` for readable content — at 11pt, it's acceptable for timestamps and metadata annotations but too small for body text or labels users need to read. Prefer
.captionor.footnoteas the minimum for readable content.
Emphasized Text Styles
Apply .bold symbolic trait to get emphasized variants:
// UIKit
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title1)
let boldDescriptor = descriptor.withSymbolicTraits(.traitBold)!
let font = UIFont(descriptor: boldDescriptor, size: 0)
// SwiftUI
Text("Bold Title")
.font(.title.bold())Actual weights by text style:
- Some styles map to medium
- Others map to semibold, bold, or heavy
- Depends on semantic hierarchy
Leading Variants
Tight Leading (reduces line height by 2pt on iOS, 1pt on watchOS):
// UIKit
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
let tightDescriptor = descriptor.withSymbolicTraits(.traitTightLeading)!
// SwiftUI
Text("Compact text")
.font(.body.leading(.tight))Loose Leading (increases line height by 2pt on iOS, 1pt on watchOS):
// SwiftUI
Text("Spacious paragraph")
.font(.body.leading(.loose))Dynamic Type
Automatic Scaling (iOS): Text styles scale automatically based on user preferences from Settings → Display & Brightness → Text Size.
Custom Fonts with Dynamic Type:
// UIKit - UIFontMetrics
let customFont = UIFont(name: "Avenir-Medium", size: 34)!
let bodyMetrics = UIFontMetrics(forTextStyle: .body)
let scaledFont = bodyMetrics.scaledFont(for: customFont)
// Also scale constants
let spacing = bodyMetrics.scaledValue(for: 20.0)// SwiftUI - .font(.custom(_:relativeTo:))
Text("Custom scaled text")
.font(.custom("Avenir-Medium", size: 34, relativeTo: .body))
// @ScaledMetric for values
@ScaledMetric(relativeTo: .body) var padding: CGFloat = 20Platform Differences
macOS
- No Dynamic Type support in AppKit
- Text style sizes optimized for macOS control sizes
- Catalyst apps use iOS sizes × 77% (legacy) or macOS-optimized sizes ("Optimize Interface for Mac")
watchOS
- Smaller text styles optimized for watch faces
- Tight leading default for compact displays
visionOS
- System fonts work identically to iOS
- Dynamic Type support included
Tracking & Leading
Tracking (Letter Spacing)
Tracking adjusts space between letters. Essential for optical size behavior.
Size-Specific Tracking Tables:
SF Pro includes tracking values that vary by point size to maintain optimal spacing:
- Larger sizes: tighter tracking
- Smaller sizes: looser tracking
Example from Apple Design Resources:
- 34pt (largeTitle): +0.016 tracking
- 17pt (body): +0.008 tracking
- 11pt (caption2): +0.06 tracking
Tight Tracking API (for fitting text):
// UIKit
textView.allowsDefaultTightening(for: .byTruncatingTail)
// SwiftUI
Text("Long text that needs to fit")
.lineLimit(1)
.minimumScaleFactor(0.5) // Allows tight trackingManual Tracking:
// UIKit
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.preferredFont(forTextStyle: .body),
.kern: 2.0 // 2pt tracking
]
// SwiftUI
Text("Tracked text")
.tracking(2.0)
.kerning(2.0) // Alternative APIImportant: Use .tracking() not .kerning() API for semantic correctness. Tracking disables ligatures when necessary; kerning does not.
Leading (Line Spacing)
Default Line Height: Calculated from font's built-in metrics (ascender + descender + line gap).
Language-Aware Adjustments: iOS 17+ automatically increases line height for scripts with tall ascenders/descenders:
- Arabic
- Thai, Lao
- Hindi, Bengali, Telugu
From WWDC 2023:
"Automatic line height adjustment for scripts with variable heights"
Manual Leading:
// UIKit
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 8.0 // 8pt additional space
// SwiftUI (iOS 13+)
Text("Custom spacing")
.lineSpacing(8.0)Line Height (iOS 26+):
.lineHeight() sets baseline-to-baseline distance directly — more intuitive than .lineSpacing() (which measures bottom-to-top).
// Presets
Text("Open layout").lineHeight(.loose)
Text("Compact layout").lineHeight(.tight)
// Precise control
Text("Scaled").lineHeight(.multiple(factor: 1.5))
Text("Fixed").lineHeight(.exact(points: 30)) // Does NOT scale with Dynamic TypeAlso available as AttributedString.lineHeight for styled strings. See axiom-swiftui (iOS 26 reference) for full API details.
Third-Party Font Tracking
New in iOS 18: Font vendors can embed tracking tables in custom fonts using STAT table + CTFont optical size attribute.
let attributes: [String: Any] = [
kCTFontOpticalSizeAttribute as String: pointSize
]
let descriptor = CTFontDescriptorCreateWithAttributes(attributes as CFDictionary)
let font = CTFontCreateWithFontDescriptor(descriptor, pointSize, nil)SwiftUI AttributedString Typography
Font Environment Interaction
Critical Pattern When using AttributedString with SwiftUI's Text, paragraph styles (like lineHeightMultiple) can be lost if fonts come from the environment instead of the attributed content.
From WWDC 2025-280:
"TextEditor substitutes the default value calculated from the environment for any AttributedStringKeys with a value of nil."
This same principle applies to Text—when your AttributedString doesn't specify a font, SwiftUI applies the environment font, which can cause it to rebuild text runs and drop or normalize paragraph style details.
The Problem
// ❌ WRONG - .font() modifier can override and drop paragraph styles
var s = AttributedString(longString)
// Set paragraph style
var p = AttributedString.ParagraphStyle()
p.lineHeightMultiple = 0.92
s.paragraphStyle = p
// ⚠️ No font set in AttributedString
Text(s)
.font(.body) // ⚠️ May rebuild runs, lose lineHeightMultipleWhy this fails: 1. AttributedString has no font attribute set (value is nil) 2. SwiftUI's .font(.body) modifier tells it "use this font for the whole run" 3. SwiftUI rebuilds text runs with the environment font 4. Paragraph styles get dropped or normalized during rebuild
The Solution
Keep typography inside the AttributedString when you need fine control:
// ✅ CORRECT - Font in AttributedString, no environment override
var s = AttributedString(longString)
// Set font INSIDE the attributed content
s.font = .system(.body) // ✅ Typography inside AttributedString
// Set paragraph style
var p = AttributedString.ParagraphStyle()
p.lineHeightMultiple = 0.92
s.paragraphStyle = p
Text(s) // ✅ No .font() modifierWhy this works: 1. Font is part of the attributed content (not nil) 2. No environment override from .font() modifier 3. SwiftUI preserves both font AND paragraph styles 4. Text runs remain intact with all attributes
When to Use Each Approach
Use Font in AttributedString (Fine Control)
var s = AttributedString("Carefully styled text")
s.font = .system(.body)
var p = AttributedString.ParagraphStyle()
p.lineHeightMultiple = 0.92
p.alignment = .leading
s.paragraphStyle = p
Text(s) // No modifierWhen to use:
- Need precise paragraph styling (line height, alignment)
- Mixing multiple fonts in one string
- Content will be displayed in both
TextandTextEditor - Preserving exact formatting from rich text editor
Use .font() Modifier (Broad Override)
Text("Simple text")
.font(.body)
.lineSpacing(4.0) // SwiftUI-level spacingWhen to use:
- Simple text without paragraph styles
- Want Dynamic Type automatic scaling
- Need SwiftUI's semantic font behavior (Dark Mode, accessibility)
- Intentionally overriding AttributedString fonts
Multiple Fonts in One String
var s = AttributedString("Title")
s.font = .system(.title).bold()
var body = AttributedString(" and body text")
body.font = .system(.body)
s.append(body)
Text(s) // ✅ No .font() modifier preserves both fontsCommon Mistake: Order Doesn't Matter
// ❌ WRONG mental model: "Create AttributedString first"
var s = AttributedString(text)
var p = AttributedString.ParagraphStyle()
p.lineHeightMultiple = 0.92
s.paragraphStyle = p
s.font = .system(.body) // ⚠️ Setting font last doesn't help if you use .font() modifier
Text(s).font(.body) // Still breaks!The issue isn't when you set the font in AttributedString. The issue is whether the attributed content carries its own font attributes versus relying on SwiftUI's .font(...) environment.
Verification Checklist
When using AttributedString with paragraph styles:
- [ ] Font set inside
AttributedString(notnil) - [ ] No
.font()modifier onTextview (unless intentionally overriding) - [ ] Paragraph styles set after or before font (order doesn't matter)
- [ ] Tested with actual content to verify line height/alignment preserved
Internationalization
Bidirectional Text
Complex Script Example (from WWDC 2021):
Kannada word "October":
- Character index 4 has split vowel → 2 glyphs
- Glyphs reorder before ligature application
- Glyph index ≠ character index
This is why TextKit 2 uses NSTextLocation instead of integer indices.
Hebrew/Arabic Selection: Single visual selection = multiple NSRanges in AttributedString due to right-to-left layout.
Line Breaking
Language-Aware (iOS 17+):
- Chinese, Japanese, Korean: break at semantic boundaries
- German: avoid breaking compound words
- English: prefer breaking at hyphens
Even Line Breaking (TextKit 2): Justified paragraphs use improved line breaking algorithm:
- Reduces stretched-out lines
- More even interword spacing
- Automatic in TextKit 2
Text Clipping Prevention
Best Practices: 1. Use Dynamic Type (auto-adjusts) 2. Set .lineLimit(nil) or .lineLimit(2...5) in SwiftUI 3. Use .minimumScaleFactor() for constrained single-line text 4. Test with large accessibility sizes
CSS & Web Typography
System UI Font Families:
font-family: system-ui; /* SF Pro */
font-family: ui-rounded; /* SF Pro Rounded */
font-family: ui-serif; /* New York */
font-family: ui-monospace; /* SF Mono */Legacy:
font-family: -apple-system; /* deprecated, use system-ui */Code Examples
Emphasized Large Title (SwiftUI)
Text("Recipe Editor")
.font(.largeTitle.bold()) // Emphasized variantCustom Font + Dynamic Type (UIKit)
let customFont = UIFont(name: "Avenir-Medium", size: 17)!
let metrics = UIFontMetrics(forTextStyle: .body)
label.font = metrics.scaledFont(for: customFont)
label.adjustsFontForContentSizeCategory = trueRounded Design (UIKit)
let descriptor = UIFontDescriptor
.preferredFontDescriptor(withTextStyle: .largeTitle)
.withDesign(.rounded)!
let font = UIFont(descriptor: descriptor, size: 0)Rounded Design (SwiftUI)
Text("Today")
.font(.largeTitle.bold())
.fontDesign(.rounded)ScaledMetric (SwiftUI)
struct RecipeView: View {
@ScaledMetric(relativeTo: .body) var padding: CGFloat = 20
var body: some View {
Text("Recipe")
.padding(padding) // Scales with Dynamic Type
}
}Resources
WWDC: 2020-10175, 2022-110381, 2023-10058
Docs: /uikit/uifontdescriptor, /uikit/uifontmetrics, /swiftui/font
Related skills
How it compares
Pick axiom-design over generic frontend-design skills when native Apple HIG and SF Symbol compliance on SwiftUI or UIKit is required.
FAQ
Which Apple frameworks does axiom-design target?
axiom-design targets native SwiftUI and UIKit development on Apple platforms. The skill applies Human Interface Guidelines, SF Symbols, typography, and adaptive layout patterns for iOS, iPadOS, and macOS screens.
What design standards does axiom-design enforce?
axiom-design enforces Apple Human Interface Guidelines including SF Symbol usage, dynamic type typography, spacing conventions, and size-class adaptive layouts so generated UI matches platform-native expectations.