
Ios Design System
- 246 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
ios-design-system: A skill for development. This provides functionality for development workflows.
Key points
- ios-design-system
Ios Design System by the numbers
- 246 all-time installs (skills.sh)
- +11 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,568 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill ios-design-systemAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 246 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use ios-design-system for development tasks?
Use ios-design-system for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with ios-design-system.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use ios-design-system for development tasks, or when ios-design-system: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to ios-design-system: ios-design-system.
Files
Airbnb iOS Design System Best Practices
Opinionated, strict design system engineering for SwiftUI iOS 26 / Swift 6.2 apps. Contains 50 rules across 8 categories, prioritized by impact. Derived from Airbnb's Design Language System (DLS), Airbnb Swift Style Guide, Apple Human Interface Guidelines, and WWDC sessions. Mandates @Equatable on every view, @Observable for state, and style protocols as the primary component API.
Mandated Architecture Alignment
This skill is designed to work alongside swift-ui-architect. All code examples follow the same non-negotiable constraints:
- Feature modules depend on
Domain+DesignSystem; no directDatadependency @Observablefor mutable UI state,ObservableObject/@Publishednever@Equatablemacro on every view- Style protocols as the primary component styling API (Airbnb DLS pattern)
- Asset catalog as the source of truth for color values
- Local SPM package for design system module boundary
Scope & Relationship to Sibling Skills
This skill is the infrastructure layer — it teaches how to BUILD the design system itself. When loaded alongside sibling skills:
| Sibling Skill | Its Focus | This Skill's Focus |
|---|---|---|
swift-ui-architect | Architecture (modular MVVM-C, route shells, protocol boundaries) | Design system infrastructure (tokens, styles, governance) |
ios-design | Using design primitives (semantic colors, typography) | Engineering the token system that provides those primitives |
ios-ui-refactor | Auditing/fixing visual quality issues | Preventing those issues via governance and automation |
ios-hig | HIG compliance patterns | Asset and component infrastructure that makes compliance easy |
Clinic Architecture Contract (iOS 26 / Swift 6.2)
All guidance in this skill assumes the clinic modular MVVM-C architecture:
- Feature modules import
Domain+DesignSystemonly (neverData, never sibling features) - App target is the convergence point and owns
DependencyContainer, concrete coordinators, and Route Shell wiring Domainstays pure Swift and defines models plus repository,*Coordinating,ErrorRouting, andAppErrorcontractsDataowns SwiftData/network/sync/retry/background I/O and implements Domain protocols- Read/write flow defaults to stale-while-revalidate reads and optimistic queued writes
- ViewModels call repository protocols directly (no default use-case/interactor layer)
When to Apply
Reference these guidelines when:
- Setting up a design system for a new iOS app
- Building token architecture (colors, typography, spacing, sizing)
- Creating reusable component styles (ButtonStyle, LabelStyle, custom DLS protocols)
- Organizing asset catalogs (colors, images, icons)
- Migrating from ad-hoc styles to a governed token system
- Preventing style drift and enforcing consistency via automation
- Building theming infrastructure for whitelabel or multi-brand apps
- Reviewing PRs for ungoverned colors, hardcoded values, or shadow tokens
Rule Categories by Priority
| Priority | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | Token Architecture | CRITICAL | token- | 6 |
| 2 | Color System Engineering | CRITICAL | color- | 7 |
| 3 | Component Style Library | CRITICAL | style- | 10 |
| 4 | Typography Scale | HIGH | type- | 5 |
| 5 | Spacing & Sizing System | HIGH | space- | 5 |
| 6 | Consistency & Governance | HIGH | govern- | 7 |
| 7 | Asset Management | MEDIUM-HIGH | asset- | 5 |
| 8 | Theme & Brand Infrastructure | MEDIUM | theme- | 5 |
Quick Reference
1. Token Architecture (CRITICAL)
- `token-three-layer-hierarchy` - Use Raw → Semantic → Component token layers
- `token-enum-over-struct` - Use caseless enums for token namespaces
- `token-single-file-per-domain` - One token file per design domain
- `token-shapestyle-extensions` - Extend ShapeStyle for dot-syntax colors
- `token-asset-catalog-source` - Source color tokens from asset catalog
- `token-avoid-over-abstraction` - Avoid over-abstracting beyond three layers
2. Color System Engineering (CRITICAL)
- `color-organized-xcassets` - Organize color assets with folder groups by role
- `color-complete-pairs` - Define both appearances for every custom color
- `color-limit-palette` - Limit custom colors to under 20 semantic tokens
- `color-no-hex-in-views` - Never use Color literals or hex in view code
- `color-system-first` - Prefer system colors before custom tokens
- `color-tint-not-brand-everywhere` - Set brand color as app tint, don't scatter it
- `color-audit-script` - Audit for ungoverned colors with a build script
3. Component Style Library (CRITICAL)
- `style-dls-protocol-pattern` - Define custom style protocols for complex DLS components
- `style-equatable-views` - Apply @Equatable to every design system view
- `style-accessibility-first` - Build accessibility into style protocols, not individual views
- `style-protocol-over-wrapper` - Use Style protocols instead of wrapper views
- `style-static-member-syntax` - Provide static member syntax for custom styles
- `style-environment-awareness` - Make styles responsive to environment values
- `style-view-for-containers-modifier-for-styling` - Views for containers, modifiers for styling
- `style-catalog-file` - One style catalog file per component type
- `style-configuration-over-parameters` - Prefer configuration structs over many parameters
- `style-preview-catalog` - Create a preview catalog for all styles
4. Typography Scale (HIGH)
- `type-scale-enum` - Define a type scale enum wrapping system styles
- `type-system-styles-first` - Use system text styles before custom ones
- `type-custom-font-registration` - Register custom fonts with a centralized extension
- `type-max-styles-per-screen` - Limit typography variations to 3-4 per screen
- `type-avoid-font-design-mixing` - Use one font design per app
5. Spacing & Sizing System (HIGH)
- `space-token-enum` - Define spacing tokens as a caseless enum
- `space-radius-tokens` - Define corner radius tokens by component type
- `space-no-magic-numbers` - Zero hardcoded numbers in view layout code
- `space-insets-pattern` - Use EdgeInsets constants for composite padding
- `space-size-tokens` - Define size tokens for common dimensions
6. Consistency & Governance (HIGH)
- `govern-naming-conventions` - Enforce consistent naming conventions across all tokens
- `govern-spm-package-boundary` - Isolate the design system as a local SPM package
- `govern-single-source-of-truth` - Every visual value has one definition point
- `govern-lint-for-tokens` - Use SwiftLint rules to enforce token usage
- `govern-design-system-directory` - Isolate tokens in a dedicated directory
- `govern-migration-incremental` - Migrate to tokens incrementally
- `govern-prevent-local-tokens` - Prevent feature modules from defining local tokens
7. Asset Management (MEDIUM-HIGH)
- `asset-separate-catalogs` - Separate asset catalogs for colors, images, icons
- `asset-sf-symbols-first` - Use SF Symbols before custom icons
- `asset-icon-export-format` - Use PDF/SVG vectors, never multiple PNGs
- `asset-image-optimization` - Use compression and on-demand resources
- `asset-naming-convention` - Consistent naming convention for all assets
8. Theme & Brand Infrastructure (MEDIUM)
- `theme-environment-key` - Use EnvironmentKey for theme propagation
- `theme-dont-over-theme` - Avoid building a theme system unless needed
- `theme-tint-for-brand` - Use .tint() as primary brand expression
- `theme-light-dark-only` - Use ColorScheme for light/dark, not custom theming
- `theme-brand-layer-separation` - Separate brand identity from system mechanics
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
{Rule Title}
{1-3 sentences explaining WHY this matters. Focus on design system maintainability, consistency, and prevention of drift.}
Incorrect ({what's wrong}):
{Bad code example - production-realistic, not strawman}
{// Comments explaining the problem}Correct ({what's right}):
{Good code example - minimal diff from incorrect}
{// Comments explaining the benefit}When NOT to use this pattern:
- {Exception 1}
- {Exception 2}
Benefits:
- {Benefit 1}
- {Benefit 2}
Reference: [{Reference Title}]({Reference URL})
{
"version": "1.0.7",
"organization": "Airbnb Engineering / Apple HIG",
"technology": "iOS Design System (SwiftUI, iOS 26 / Swift 6.2)",
"date": "February 2026",
"abstract": "Airbnb-aligned design system engineering for SwiftUI iOS 26 / Swift 6.2 apps. Contains 50 rules across 8 categories: Token Architecture (CRITICAL), Color System Engineering (CRITICAL), Typography Scale (HIGH), Spacing & Sizing (HIGH), Component Style Library (CRITICAL), Asset Management (MEDIUM-HIGH), Theme & Brand Infrastructure (MEDIUM), and Consistency & Governance (HIGH). Mandates @Equatable on every view, @Observable state, DLS-style protocols for component styling, and automated governance via SwiftLint and SPM package boundaries, aligned with modular MVVM-C feature boundaries. Aligned with the iOS 26 / Swift 6.2 clinic modular MVVM-C architecture.",
"references": [
"https://medium.com/airbnb-engineering/unlocking-swiftui-at-airbnb-ea58f50cde49",
"https://airbnb.tech/uncategorized/understanding-and-improving-swiftui-performance/",
"https://github.com/airbnb/swift",
"https://developer.apple.com/design/human-interface-guidelines/",
"https://developer.apple.com/design/human-interface-guidelines/color",
"https://developer.apple.com/design/human-interface-guidelines/typography",
"https://developer.apple.com/videos/play/wwdc2024/10150/",
"https://developer.apple.com/videos/play/wwdc2023/10115/",
"https://movingparts.io/styling-components-in-swiftui",
"https://fatbobman.com/en/posts/custom-button-style-in-swiftui/",
"https://www.magnuskahr.dk/posts/2025/06/swiftui-design-system-considerations-semantic-colors/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Token Architecture (token)
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Impact: CRITICAL Description: The foundation layer — how to define, organize, and layer design tokens (raw → semantic → component) determines whether the entire system stays consistent or drifts into ad-hoc chaos.
2. Color System Engineering (color)
Impact: CRITICAL Description: Colors are the most visible and most duplicated tokens in any app. A well-engineered color system eliminates scattered Color literals and survives rebrands with zero view-level changes.
3. Component Style Library (style)
Impact: CRITICAL Description: Airbnb's DLS uses style protocols as the primary component API. SwiftUI's built-in Style protocols (ButtonStyle, ToggleStyle) plus custom DLS-style protocols for complex components ensure every variant inherits accessibility, animation, and interaction behavior. All design system views must use @Equatable for optimal diffing performance.
4. Typography Scale (type)
Impact: HIGH Description: Typography drives visual hierarchy. A reusable type scale prevents the proliferation of .system(size:) calls and ensures Dynamic Type support is baked in, not bolted on.
5. Spacing & Sizing System (space)
Impact: HIGH Description: Inconsistent spacing is the most common cause of "something feels off" in production apps. A spacing token system eliminates ad-hoc pixel values and creates visual rhythm.
6. Consistency & Governance (govern)
Impact: HIGH Description: Without governance, design systems decay. Airbnb's approach uses SPM package boundaries, SwiftLint custom rules, consistent naming conventions (Airbnb Swift Style Guide), and automated auditing to enforce token usage and prevent drift.
7. Asset Management (asset)
Impact: MEDIUM-HIGH Description: Poorly organized asset catalogs lead to duplicate images, inconsistent icon treatments, and bloated bundles. Structured asset management keeps the visual system lean and discoverable.
8. Theme & Brand Infrastructure (theme)
Impact: MEDIUM Description: Environment-based theming allows brand identity to be layered on top of the system without polluting individual views with conditional logic. Uses @Observable and @Environment per swift-ui-architect constraints.
Use PDF Vector or SVG for Custom Icons, Never Multiple PNGs
A 30-icon set exported as PNGs produces 90 files (3 scales each). The same set as PDF vectors is 30 files that scale to any resolution — including future displays with higher pixel densities. Vectors also look crisp at non-standard sizes (e.g., when icons appear in Dynamic Type-scaled contexts).
Incorrect (raster PNGs at multiple scales):
Icons.xcassets/
└── icon-bookmark.imageset/
├── Contents.json
├── icon-bookmark.png // 24×24 @1x
├── icon-bookmark@2x.png // 48×48 @2x
└── icon-bookmark@3x.png // 72×72 @3x// Contents.json — three raster entries
{
"images": [
{ "filename": "icon-bookmark.png", "scale": "1x" },
{ "filename": "icon-bookmark@2x.png", "scale": "2x" },
{ "filename": "icon-bookmark@3x.png", "scale": "3x" }
]
}Correct (single PDF vector with Preserve Vector Data):
Icons.xcassets/
└── icon-bookmark.imageset/
├── Contents.json
└── icon-bookmark.pdf // Single vector file// Contents.json — single vector, preserves vector data, renders as template
{
"images": [
{ "filename": "icon-bookmark.pdf", "idiom": "universal" }
],
"properties": {
"preserves-vector-representation": true,
"template-rendering-intent": "template"
}
}// In SwiftUI — template rendering lets you tint freely
struct BookmarkButton: View {
let isBookmarked: Bool
var body: some View {
Button(action: toggleBookmark) {
Image("icon-bookmark")
.foregroundStyle(isBookmarked ? .accentPrimary : .labelTertiary)
}
}
}Key Xcode settings for vector icons:
| Setting | Value | Why |
|---|---|---|
| Preserve Vector Data | Checked | Keeps sharp at any size, not just the base point size |
| Render As | Template Image | Allows .foregroundStyle() tinting |
| Scales | Single Scale | One vector file, no @2x/@3x needed |
SVG is also supported (Xcode 12+) and works identically to PDF. Choose whichever your design tool exports more cleanly. Figma exports SVG natively; Sketch historically exports cleaner PDFs.
Use Asset Catalog Compression and On-Demand Resources
App Store download size directly affects conversion — Apple reports a 1% drop in installs for every 6 MB increase over 100 MB. Images are usually the largest contributor to bundle size. The asset catalog's built-in compression and On-Demand Resources (ODR) are the two primary tools for keeping the bundle lean without sacrificing visual quality.
Incorrect (unoptimized full-resolution images in main bundle):
// All 4K marketing images baked directly into the app binary
// Onboarding images alone add 15 MB to the bundle
struct OnboardingView: View {
let pages = [
"onboarding-hero-4k", // 3840×2160, 4.2 MB
"onboarding-features-4k", // 3840×2160, 3.8 MB
"onboarding-community-4k", // 3840×2160, 5.1 MB
]
var body: some View {
TabView {
ForEach(pages, id: \.self) { page in
Image(page)
.resizable()
.aspectRatio(contentMode: .fill)
}
}
.tabViewStyle(.page)
}
}Correct (optimized dimensions, compression, and ODR for non-essential assets):
// Images sized to actual display dimensions (iPhone 15 Pro Max = 1290×2796 logical)
// Asset catalog compression enabled, onboarding images delivered via ODR
struct OnboardingView: View {
@State private var resourceRequest: NSBundleResourceRequest?
@State private var imagesLoaded = false
// These images are tagged "onboarding" in asset catalog → ODR
let pages = [
"onboarding-hero", // 1290×860, compressed, ~180 KB
"onboarding-features", // 1290×860, compressed, ~150 KB
"onboarding-community", // 1290×860, compressed, ~200 KB
]
var body: some View {
Group {
if imagesLoaded {
TabView {
ForEach(pages, id: \.self) { page in
Image(page)
.resizable()
.aspectRatio(contentMode: .fill)
}
}
.tabViewStyle(.page)
} else {
ProgressView("Loading...")
}
}
.task { await loadOnboardingAssets() }
}
private func loadOnboardingAssets() async {
let request = NSBundleResourceRequest(tags: ["onboarding"])
self.resourceRequest = request
do {
try await request.beginAccessingResources()
imagesLoaded = true
} catch {
// Fallback: images may already be cached or included in thin bundle
imagesLoaded = true
}
}
}Asset catalog compression settings (in Build Settings):
| Setting | Recommended Value | Effect |
|---|---|---|
| Compress PNG Files | YES | Lossless PNG optimization at build time |
| Asset Catalog Compiler - Optimization | space | Prioritize smaller binary over build speed |
| On Demand Resources Initial Install Tags | (empty for onboarding) | Exclude tagged assets from initial download |
ODR tag strategy for typical apps:
| Tag | Content | When Loaded |
|---|---|---|
| (no tag) | Core UI assets, brand logo, tab icons | Always in bundle |
onboarding | Welcome flow illustrations | First launch only |
tutorials | Help/tutorial screenshots | On demand |
seasonal | Holiday/promotional banners | On demand |
Use Consistent Naming Convention for All Assets
When three developers name the same background color bg_home, homeBackground, and HomeBG, you get three assets that do the same thing. Consistent naming is the cheapest governance tool in a design system — it makes duplicates obvious, autocomplete useful, and audit scripts trivial to write.
Incorrect (mixed naming conventions across asset types):
Assets.xcassets/
├── bg_home.colorset/ // snake_case
├── homeBackground.colorset/ // camelCase
├── HomeBG.colorset/ // PascalCase abbreviation
├── hero-image-home.imageset/ // kebab-case with type prefix
├── imgHero.imageset/ // camelCase with type abbreviation
├── ic_back.imageset/ // Android-style snake_case
├── close-icon.imageset/ // type suffix instead of prefix
└── CloseBtn.imageset/ // PascalCase abbreviationCorrect (structured naming by asset type):
Colors.xcassets/
├── Background/
│ ├── backgroundPrimary.colorset/ // camelCase, semantic role
│ ├── backgroundSecondary.colorset/
│ ├── backgroundSurface.colorset/
│ └── backgroundElevated.colorset/
├── Label/
│ ├── labelPrimary.colorset/
│ ├── labelSecondary.colorset/
│ └── labelTertiary.colorset/
├── Fill/
│ ├── fillPrimary.colorset/
│ └── fillQuaternary.colorset/
├── Separator/
│ └── separatorOpaque.colorset/
└── Accent/
├── accentPrimary.colorset/
└── accentSecondary.colorset/
Images.xcassets/
├── Illustrations/
│ ├── onboarding-welcome.imageset/ // kebab-case, context-first
│ ├── onboarding-complete.imageset/
│ └── empty-state-no-results.imageset/
├── Backgrounds/
│ ├── gradient-hero-home.imageset/
│ └── gradient-hero-profile.imageset/
└── Photos/
└── placeholder-avatar.imageset/
Icons.xcassets/
├── Navigation/
│ ├── icon-arrow-back.imageset/ // kebab-case, prefixed
│ ├── icon-close.imageset/
│ └── icon-menu.imageset/
└── Brand/
├── icon-brand-logo.imageset/
└── icon-brand-wordmark.imageset/// Swift extensions mirror the naming convention exactly
extension ShapeStyle where Self == Color {
// Background
static var backgroundPrimary: Color { Color("backgroundPrimary") }
static var backgroundSurface: Color { Color("backgroundSurface") }
// Label
static var labelPrimary: Color { Color("labelPrimary") }
static var labelSecondary: Color { Color("labelSecondary") }
}
extension Image {
// Illustrations — match asset catalog name exactly
static let onboardingWelcome = Image("onboarding-welcome")
static let emptyStateNoResults = Image("empty-state-no-results")
}Naming rules summary:
| Asset Type | Case | Pattern | Example |
|---|---|---|---|
| Colors | camelCase | {role}{Variant} | backgroundPrimary |
| Images | kebab-case | {context}-{descriptor} | onboarding-welcome |
| Icons | kebab-case | icon-{descriptor} | icon-arrow-back |
| Folders | PascalCase | {Category} | Background/, Navigation/ |
The key principle: color names match Swift property conventions (camelCase), image/icon names use kebab-case for readability in the asset catalog sidebar where they appear as plain strings.
Use Separate Asset Catalogs for Colors, Images, and Icons
A monolithic Assets.xcassets becomes unmanageable past 50 items. Xcode's sidebar has no search within catalogs, so finding backgroundTertiary among 200 mixed assets means scrolling. Separate catalogs also reduce merge conflicts — color changes never conflict with image additions.
Incorrect (everything in one catalog):
MyApp/
└── Assets.xcassets/
├── AccentColor.colorset/
├── AppIcon.appiconset/
├── backgroundPrimary.colorset/
├── backgroundSurface.colorset/
├── brandLogo.imageset/
├── hero-onboarding.imageset/
├── icon-back.imageset/
├── icon-close.imageset/
├── labelPrimary.colorset/
├── labelSecondary.colorset/
├── onboarding-step1.imageset/
├── separatorOpaque.colorset/
└── ... (180 more items)Correct (purpose-specific catalogs with folder groups):
MyApp/Resources/
├── Assets.xcassets/ // App icon + accent color only
│ ├── AccentColor.colorset/
│ └── AppIcon.appiconset/
├── Colors.xcassets/ // All semantic colors
│ ├── Background/
│ │ ├── backgroundPrimary.colorset/
│ │ ├── backgroundSecondary.colorset/
│ │ └── backgroundSurface.colorset/
│ ├── Label/
│ │ ├── labelPrimary.colorset/
│ │ └── labelSecondary.colorset/
│ └── Separator/
│ └── separatorOpaque.colorset/
├── Images.xcassets/ // Photos, illustrations, backgrounds
│ ├── Illustrations/
│ │ ├── onboarding-welcome.imageset/
│ │ └── onboarding-complete.imageset/
│ └── Marketing/
│ └── hero-homepage.imageset/
└── Icons.xcassets/ // Custom icons (non-SF-Symbol)
├── Navigation/
│ ├── icon-back.imageset/
│ └── icon-close.imageset/
└── Brand/
└── brand-logo.imageset/All catalogs in the same target are accessible via Color("backgroundPrimary") or Image("brand-logo") regardless of which .xcassets file they live in. The separation is purely organizational — zero runtime cost.
Use SF Symbols Before Custom Icons
Every custom icon asset is a maintenance liability: it needs @1x/@2x/@3x variants (or a PDF vector), light/dark variants, and manual weight matching to adjacent text. SF Symbols handle all of this automatically and match the system font weight of surrounding text. With 5,000+ symbols in SF Symbols 5, most common actions already have a high-quality glyph.
Incorrect (custom PNG icons for common actions):
// TabBarView.swift — custom assets for standard actions
struct TabBarView: View {
var body: some View {
TabView {
HomeView()
.tabItem {
Image("tab-home") // Custom PNG, doesn't scale with Dynamic Type
Text("Home")
}
SearchView()
.tabItem {
Image("tab-search") // Custom PNG, fixed weight
Text("Search")
}
ProfileView()
.tabItem {
Image("tab-profile") // Custom PNG, no weight adaptation
Text("Profile")
}
}
}
}Correct (SF Symbols for standard actions, custom only for brand-specific):
struct TabBarView: View {
var body: some View {
TabView {
HomeView()
.tabItem {
Label("Home", systemImage: "house")
}
SearchView()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
ProfileView()
.tabItem {
Label("Profile", systemImage: "person.crop.circle")
}
}
}
}
// Only use custom assets for brand-specific icons with no SF Symbol match
struct PartnerBadge: View {
var body: some View {
Image("brand-partner-badge") // Unique brand mark, no system equivalent
.renderingMode(.template)
.foregroundStyle(.accentPrimary)
}
}Before requesting a custom icon from design, search the SF Symbols app (or SFSymbolReference.com) for a match. If no exact match exists, check if a related symbol with a custom rendering mode works. Only create a custom asset when no SF Symbol is even close.
Audit for Ungoverned Colors with a Build Script
Design token governance requires enforcement. Documented guidelines alone do not prevent developers from typing Color(hex: "#333") in a view — especially under deadline pressure. A build phase script or linter rule that flags raw color initializers in view code catches violations at build time, before they reach code review. The script exempts the design system's own token definition files where raw values are expected.
Incorrect (no enforcement — ungoverned colors accumulate):
// No build script, no linter rule.
// Over 6 months, the codebase accumulates:
// Features/Profile/ProfileView.swift
.foregroundStyle(Color(hex: "#2D3436"))
// Features/Feed/FeedCardView.swift
.background(Color(red: 0.95, green: 0.95, blue: 0.97))
// Features/Settings/SettingsView.swift
.foregroundStyle(Color(.sRGB, red: 0.56, green: 0.56, blue: 0.58))
// Features/Chat/MessageBubble.swift
.background(Color(hex: "#E8F5E9"))
// Nobody notices. 40+ ungoverned colors across the codebase.
// Dark mode is broken in 12 places. Rebrand will miss these.Correct (build phase script catches violations):
#!/bin/bash
# Build Phase: "Check Ungoverned Colors"
# Add via Xcode → Target → Build Phases → New Run Script Phase
# Place AFTER "Compile Sources" phase
# Directories to scan (your feature/view code)
SCAN_DIRS="${SRCROOT}/Sources ${SRCROOT}/Features ${SRCROOT}/Views"
# Directories to exclude (where raw color values are allowed)
EXCLUDE_DIR="DesignSystem"
# Patterns that indicate ungoverned colors
PATTERNS=(
'Color(hex:'
'Color(red:'
'Color(.sRGB'
'Color(hue:'
'Color(.displayP3'
'UIColor(red:'
'UIColor(hex:'
'#colorLiteral'
)
FOUND_VIOLATIONS=0
for pattern in "${PATTERNS[@]}"; do
RESULTS=$(grep -rn "$pattern" \
--include="*.swift" \
--exclude-dir="$EXCLUDE_DIR" \
--exclude-dir="Tests" \
--exclude-dir=".build" \
$SCAN_DIRS 2>/dev/null)
if [ -n "$RESULTS" ]; then
echo "error: Ungoverned color found. Use semantic tokens from DesignSystem instead."
echo "$RESULTS" | while IFS= read -r line; do
FILE=$(echo "$line" | cut -d: -f1)
LINE_NUM=$(echo "$line" | cut -d: -f2)
echo "$FILE:$LINE_NUM: error: Ungoverned color: $pattern — Replace with a semantic token (.textPrimary, .backgroundSurface, etc.)"
done
FOUND_VIOLATIONS=1
fi
done
if [ "$FOUND_VIOLATIONS" -eq 1 ]; then
exit 1
fiFor SwiftLint-based enforcement, see `govern-lint-for-tokens` which provides comprehensive SwiftLint custom rules covering colors, spacing, typography, and radii.
CI integration for comprehensive auditing:
// Tests/DesignSystemTests/ColorGovernanceTests.swift
import XCTest
final class ColorGovernanceTests: XCTestCase {
func testNoUngovernedColorsInFeatureCode() throws {
let sourceRoot = ProcessInfo.processInfo.environment["SRCROOT"] ?? "."
let featuresPath = "\(sourceRoot)/Features"
let ungovernedPatterns = [
"Color(hex:",
"Color(red:",
"Color(.sRGB",
"Color(hue:",
"#colorLiteral",
]
let fileManager = FileManager.default
let enumerator = fileManager.enumerator(atPath: featuresPath)
var violations: [(file: String, line: Int, pattern: String)] = []
while let file = enumerator?.nextObject() as? String {
guard file.hasSuffix(".swift") else { continue }
let fullPath = "\(featuresPath)/\(file)"
let contents = try String(contentsOfFile: fullPath, encoding: .utf8)
let lines = contents.components(separatedBy: .newlines)
for (index, line) in lines.enumerated() {
for pattern in ungovernedPatterns {
if line.contains(pattern) {
violations.append((file: file, line: index + 1, pattern: pattern))
}
}
}
}
XCTAssertTrue(
violations.isEmpty,
"Found \(violations.count) ungoverned color(s):\n" +
violations.map { " \($0.file):\($0.line) — \($0.pattern)" }.joined(separator: "\n")
)
}
}Benefits:
- Violations are caught at build time, not in code review (faster feedback)
- SwiftLint integration shows inline Xcode warnings with fix suggestions
- CI test ensures no regressions even if local build scripts are skipped
- Exemption for DesignSystem directory keeps the system's own definitions clean
- New team members cannot accidentally bypass the token system
Reference: SwiftLint Custom Rules, Xcode Build Phases — Apple Developer
Define Both Appearances for Every Custom Color
When a color set in the asset catalog has only an "Any Appearance" value and no "Dark" variant, iOS uses the light-mode value in dark mode verbatim. A white background stays white in dark mode. Dark text on a dark-mode background becomes invisible. This is the single most common visual defect in production iOS apps and it is entirely preventable: every custom color set MUST define both Any Appearance and Dark Appearance values at the moment it is created, with no exceptions.
Incorrect (color set with only "Any Appearance"):
// backgroundSurface.colorset/Contents.json — MISSING DARK VARIANT
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"red" : "1.000",
"green" : "1.000",
"blue" : "1.000",
"alpha" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : { "version" : 1, "author" : "xcode" }
}
// Result: White background in dark mode → unreadable text, blinding UICorrect (both appearances defined):
// backgroundSurface.colorset/Contents.json — COMPLETE
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"red" : "1.000",
"green" : "1.000",
"blue" : "1.000",
"alpha" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"red" : "0.176",
"green" : "0.176",
"blue" : "0.184",
"alpha" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : { "version" : 1, "author" : "xcode" }
}Enforcement with a PR checklist or automation:
// Test that validates all color sets have dark variants
// Place in your test target
import XCTest
final class ColorAssetTests: XCTestCase {
func testAllColorSetsHaveDarkVariant() throws {
let catalogURL = Bundle.main.url(forResource: "Colors", withExtension: "xcassets")
// Walk the catalog directory and parse each Contents.json
// Assert that every color set contains an appearance entry with "value": "dark"
let colorSetURLs = try FileManager.default
.contentsOfDirectory(at: catalogURL!, includingPropertiesForKeys: nil)
.filter { $0.pathExtension == "colorset" }
for colorSetURL in colorSetURLs {
let contentsURL = colorSetURL.appendingPathComponent("Contents.json")
let data = try Data(contentsOf: contentsURL)
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let colors = json["colors"] as! [[String: Any]]
let hasDark = colors.contains { entry in
guard let appearances = entry["appearances"] as? [[String: String]] else {
return false
}
return appearances.contains { $0["value"] == "dark" }
}
XCTAssertTrue(
hasDark,
"Color set '\(colorSetURL.lastPathComponent)' is missing a Dark appearance variant"
)
}
}
}High Contrast variants (recommended for accessibility):
backgroundSurface.colorset/
├── Any Appearance → #FFFFFF
├── Dark → #2C2C2E
├── High Contrast (Any) → #FFFFFF // Often same, but explicit
└── High Contrast (Dark) → #000000 // Maximum contrastAdding High Contrast variants earns the app better Accessibility Inspector scores and serves users with low vision. While optional, defining them at color creation time costs 30 seconds and prevents retrofitting later.
Benefits:
- Zero dark mode regressions: every color resolves correctly in both appearances from day one
- Automated test catches missing variants before they reach production
- High Contrast variants added at creation time are 10x cheaper than retrofitting after launch
- Accessibility audits pass without remediation
Reference: Supporting Dark Mode — Apple Developer, WWDC19 — Implementing Dark Mode on iOS
Limit Custom Colors to Under 20 Semantic Tokens
Design systems grow by accretion. A developer needs a "slightly lighter" background and adds backgroundLightGray. Another adds backgroundOffWhite which is 2 hex digits different. Within a year, the app has 40 custom colors, a third of which are near-duplicates that nobody can confidently remove. Constraining the palette to 15-20 semantic tokens forces consolidation up front. If a new color cannot fit an existing token, it is either a genuine new role (add it) or a visual deviation that should be realigned to the system (don't add it).
Incorrect (bloated palette with near-duplicates):
extension ShapeStyle where Self == Color {
// Backgrounds — 8 tokens, several near-identical
static var backgroundPrimary: Color { Color("backgroundPrimary") } // #F2F2F7
static var backgroundSecondary: Color { Color("backgroundSecondary") } // #FFFFFF
static var backgroundTertiary: Color { Color("backgroundTertiary") } // #F5F5F5
static var backgroundLight: Color { Color("backgroundLight") } // #FAFAFA ← 3px from tertiary
static var backgroundCard: Color { Color("backgroundCard") } // #FFFFFF ← duplicate of secondary
static var backgroundSheet: Color { Color("backgroundSheet") } // #FFFFFF ← another duplicate
static var backgroundInput: Color { Color("backgroundInput") } // #F8F8F8 ← 3px from light
static var backgroundHover: Color { Color("backgroundHover") } // #F0F0F0
// Text — 5 tokens, one never used
static var textPrimary: Color { Color("textPrimary") }
static var textSecondary: Color { Color("textSecondary") }
static var textTertiary: Color { Color("textTertiary") }
static var textQuaternary: Color { Color("textQuaternary") } // Used in 1 view
static var textPlaceholder: Color { Color("textPlaceholder") } // Same value as tertiary
// ... 20 more tokens, total: 35+
}Correct (consolidated palette of ~16 tokens):
extension ShapeStyle where Self == Color {
// MARK: - Background (3 levels of elevation)
static var backgroundPrimary: Color { Color("backgroundPrimary") } // Base canvas
static var backgroundSurface: Color { Color("backgroundSurface") } // Cards, cells
static var backgroundElevated: Color { Color("backgroundElevated") } // Sheets, popovers
// MARK: - Text (3 levels of emphasis)
static var textPrimary: Color { Color("textPrimary") } // Headlines, body
static var textSecondary: Color { Color("textSecondary") } // Captions, metadata
static var textTertiary: Color { Color("textTertiary") } // Placeholders, disabled
// MARK: - Border (2 levels)
static var borderDefault: Color { Color("borderDefault") } // Standard borders
static var borderSubtle: Color { Color("borderSubtle") } // Faint dividers
// MARK: - Interactive (2 accents)
static var accentPrimary: Color { Color("accentPrimary") } // Primary actions, tint
static var accentSecondary: Color { Color("accentSecondary") } // Secondary actions
// MARK: - Status (4 semantic states)
static var statusSuccess: Color { Color("statusSuccess") } // Positive
static var statusWarning: Color { Color("statusWarning") } // Caution
static var statusError: Color { Color("statusError") } // Negative
static var statusInfo: Color { Color("statusInfo") } // Informational
// MARK: - Special (1-2 for specific needs)
static var textInverse: Color { Color("textInverse") } // Text on dark fills
static var overlayDimmed: Color { Color("overlayDimmed") } // Scrim behind modals
}
// Total: 16 tokens. Covers 95% of use cases.What to do when a developer requests a new color:
Decision tree:
1. Does an existing token serve this purpose?
→ Yes: Use it. "backgroundSurface" works for cards, cells, and inputs.
→ No: Continue to step 2.
2. Is this a genuinely new semantic ROLE?
(Not a visual variation, but a different purpose)
→ Yes: Add it. Example: "textLink" for tappable text is a new role.
→ No: Continue to step 3.
3. Can the design be adjusted to use an existing token?
→ Yes: Propose the alignment to the designer.
→ No: This is rare. Discuss with the team before adding.Recommended minimum set for a production app:
| Role | Count | Examples |
|---|---|---|
| Backgrounds | 3 | primary, surface, elevated |
| Text | 3 | primary, secondary, tertiary |
| Borders | 1-2 | default, subtle |
| Accents | 1-2 | primary, secondary |
| Status | 3-4 | success, warning, error, info |
| Special | 1-2 | inverse, overlay |
| Total | 12-16 |
Benefits:
- Near-duplicates are impossible when the budget is 16 tokens
- Developers spend zero time deciding between "backgroundLight" and "backgroundCard"
- Visual consistency is enforced by constraint, not by discipline
- Designers and developers share a small, memorizable vocabulary
Reference: Material Design Color System, WWDC23 — Design with SwiftUI
Never Use Color Literals or Hex Initializers in View Code
A Color(hex: "#3B82F6") embedded in a view is invisible to the design system. It cannot be found by searching for token names, it will not update during a rebrand, it has no dark mode variant, and it silently breaks visual consistency. The design system works only if every view resolves colors through the semantic token layer. Zero tolerance for raw color initialization in view files is the single most important governance rule for maintaining system integrity over time.
Incorrect (raw color values in views):
struct PaymentConfirmation: View {
let amount: Decimal
let isSuccessful: Bool
var body: some View {
VStack(spacing: 16) {
Image(systemName: isSuccessful ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.system(size: 48))
.foregroundStyle(isSuccessful
? Color(red: 0.2, green: 0.78, blue: 0.35) // Ungoverned green
: Color(hex: "#FF3B30")) // Ungoverned red
Text(isSuccessful ? "Payment Successful" : "Payment Failed")
.font(.title2.bold())
.foregroundStyle(Color(hex: "#1A1A2E")) // Ungoverned text color
Text(amount, format: .currency(code: "GBP"))
.font(.largeTitle.bold())
.foregroundStyle(Color(.sRGB, red: 0.1, green: 0.1, blue: 0.18)) // Another ungoverned color
Button("Done") { }
.buttonStyle(.borderedProminent)
.tint(Color(hex: "#5856D6")) // Ungoverned brand color
}
.padding(24)
.background(Color(hex: "#FFFFFF")) // Ungoverned background
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
// 6 ungoverned colors in a single view. None will adapt to dark mode.Correct (all colors through semantic tokens):
@Equatable
struct PaymentConfirmation: View {
let amount: Decimal
let isSuccessful: Bool
var body: some View {
VStack(spacing: Spacing.md) {
Image(systemName: isSuccessful ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.system(size: 48))
.foregroundStyle(isSuccessful ? .statusSuccess : .statusError)
Text(isSuccessful ? "Payment Successful" : "Payment Failed")
.font(.title2.bold())
.foregroundStyle(.textPrimary)
Text(amount, format: .currency(code: "GBP"))
.font(.largeTitle.bold())
.foregroundStyle(.textPrimary)
Button("Done") { }
.buttonStyle(.borderedProminent)
// .tint inherited from app root — no explicit color needed
}
.padding(Spacing.lg)
.background(.backgroundElevated)
.clipShape(RoundedRectangle(cornerRadius: Radius.xl))
}
}
// 0 ungoverned colors. Every color adapts to dark mode automatically.Where raw color values ARE allowed:
// 1. Inside the design system token definitions (and ONLY there)
// File: DesignSystem/Sources/Colors.swift
extension ShapeStyle where Self == Color {
static var accentPrimary: Color { Color("accentPrimary") } // Asset catalog reference
}
// 2. Dynamic colors derived from content (user avatar colors, image extraction)
AsyncImage(url: user.avatarURL) { image in
image.resizable()
} placeholder: {
Rectangle().fill(Color(hex: user.profileColorHex)) // User-generated, not a design token
}
// 3. One-off animation or visual effects
Circle()
.fill(
RadialGradient(
colors: [.accentPrimary, .accentPrimary.opacity(0)], // Derived from tokens
center: .center,
startRadius: 0,
endRadius: 100
)
)Quick reference — what to search for in code review:
| Pattern | Verdict |
|---|---|
.foregroundStyle(.textPrimary) | Governed - correct |
.foregroundStyle(.primary) | System color - correct |
.foregroundStyle(Color("textPrimary")) | Verbose but governed - acceptable |
.foregroundStyle(Color(hex: "#333")) | UNGOVERNED - reject |
.foregroundStyle(Color(red: 0.2, green: 0.2, blue: 0.2)) | UNGOVERNED - reject |
.foregroundStyle(Color(.sRGB, red: 0.2, green: 0.2, blue: 0.2)) | UNGOVERNED - reject |
.foregroundStyle(Color(uiColor: .label)) | System UIColor - acceptable |
Benefits:
- Every color in the app is auditable by searching for token names
- Dark mode works without any per-view logic
- Rebranding is a token-level change, not a codebase-wide find-and-replace
- Code review becomes binary: uses a token or it doesn't
Reference: Color — Apple Human Interface Guidelines
Organize Color Assets with Folder Groups by Role
An asset catalog with 30+ color sets at the root level becomes an unscannable wall of names. Developers scroll past colors they need, add duplicates because they cannot find existing ones, and waste time in code review verifying whether a color already exists. Folder groups inside the asset catalog organize colors by semantic role — background, text, border, status, brand — making the catalog self-documenting and navigable.
Incorrect (flat list of colors at root level):
Colors.xcassets/
├── accentPrimary.colorset/
├── accentSecondary.colorset/
├── backgroundElevated.colorset/
├── backgroundPrimary.colorset/
├── backgroundSurface.colorset/
├── borderDefault.colorset/
├── borderSubtle.colorset/
├── brandGradientEnd.colorset/
├── brandGradientStart.colorset/
├── brandPrimary.colorset/
├── brandSecondary.colorset/
├── overlayDimmed.colorset/
├── statusError.colorset/
├── statusInfo.colorset/
├── statusSuccess.colorset/
├── statusWarning.colorset/
├── textInverse.colorset/
├── textLink.colorset/
├── textPrimary.colorset/
├── textSecondary.colorset/
└── textTertiary.colorset/
// 20+ items in a flat list — "Is there a borderFocused? Let me scroll through everything..."Correct (folder groups by semantic role):
Colors.xcassets/
├── Background/
│ ├── backgroundPrimary.colorset/ // Main app background
│ │ └── Contents.json
│ ├── backgroundSurface.colorset/ // Card/sheet surface
│ │ └── Contents.json
│ └── backgroundElevated.colorset/ // Elevated surface (modal, popover)
│ └── Contents.json
├── Text/
│ ├── textPrimary.colorset/ // Headlines, body text
│ │ └── Contents.json
│ ├── textSecondary.colorset/ // Captions, metadata
│ │ └── Contents.json
│ ├── textTertiary.colorset/ // Placeholders, disabled
│ │ └── Contents.json
│ ├── textInverse.colorset/ // Text on dark backgrounds
│ │ └── Contents.json
│ └── textLink.colorset/ // Tappable text
│ └── Contents.json
├── Border/
│ ├── borderDefault.colorset/ // Standard borders
│ │ └── Contents.json
│ └── borderSubtle.colorset/ // Subtle dividers
│ └── Contents.json
├── Status/
│ ├── statusSuccess.colorset/ // Positive outcomes
│ │ └── Contents.json
│ ├── statusWarning.colorset/ // Caution states
│ │ └── Contents.json
│ ├── statusError.colorset/ // Error states
│ │ └── Contents.json
│ └── statusInfo.colorset/ // Informational states
│ └── Contents.json
├── Brand/
│ ├── accentPrimary.colorset/ // Primary brand / tint
│ │ └── Contents.json
│ ├── accentSecondary.colorset/ // Secondary accent
│ │ └── Contents.json
│ └── brandPrimary.colorset/ // Brand identity color
│ └── Contents.json
└── Overlay/
└── overlayDimmed.colorset/ // Scrim behind modals
└── Contents.jsonSwift API mirrors the asset catalog structure with MARK comments:
// Colors.swift
import SwiftUI
extension ShapeStyle where Self == Color {
// MARK: - Background
static var backgroundPrimary: Color { Color("backgroundPrimary") }
static var backgroundSurface: Color { Color("backgroundSurface") }
static var backgroundElevated: Color { Color("backgroundElevated") }
// MARK: - Text
static var textPrimary: Color { Color("textPrimary") }
static var textSecondary: Color { Color("textSecondary") }
static var textTertiary: Color { Color("textTertiary") }
static var textInverse: Color { Color("textInverse") }
static var textLink: Color { Color("textLink") }
// MARK: - Border
static var borderDefault: Color { Color("borderDefault") }
static var borderSubtle: Color { Color("borderSubtle") }
// MARK: - Status
static var statusSuccess: Color { Color("statusSuccess") }
static var statusWarning: Color { Color("statusWarning") }
static var statusError: Color { Color("statusError") }
static var statusInfo: Color { Color("statusInfo") }
// MARK: - Brand
static var accentPrimary: Color { Color("accentPrimary") }
static var accentSecondary: Color { Color("accentSecondary") }
}Note on folder groups and Color() initialization: Folder groups in asset catalogs do NOT affect the string used in Color("name"). A color at Background/backgroundPrimary.colorset is still referenced as Color("backgroundPrimary"), not Color("Background/backgroundPrimary"). The folders are purely organizational in Xcode.
Benefits:
- Xcode's asset catalog sidebar shows collapsible groups — scan only the category you need
- Duplicate detection is immediate: "Is there already a background color?" — expand the Background folder
- Code review matches: MARK sections in Swift mirror folder groups in the asset catalog
- Adding a new color follows a clear pattern: pick the role folder, create the color set
Reference: Asset Catalog Format — Apple Developer
Prefer System Colors Before Defining Custom Tokens
SwiftUI ships with a complete set of semantic system colors that auto-adapt to light mode, dark mode, high contrast, and elevated contrast — for free. Every custom color token you add is a value you must maintain across all four appearance combinations, test manually, and keep synchronized with design updates. Before adding any custom token, verify that a system color does not already serve the same purpose. The design system should extend Apple's system, not replace it.
Incorrect (custom tokens that map to identical system color values):
extension ShapeStyle where Self == Color {
// These map to the EXACT same appearance as system colors — unnecessary maintenance debt
static var textPrimary: Color { Color("textPrimary") } // Visually identical to .primary
static var textSecondary: Color { Color("textSecondary") } // Visually identical to .secondary
static var textDisabled: Color { Color("textDisabled") } // Visually identical to .tertiary
static var backgroundBase: Color { Color("backgroundBase") } // Visually identical to Color(.systemBackground)
static var separator: Color { Color("separator") } // Visually identical to .separator (built-in)
// If your brand text colors use specific hex values that DIFFER from system colors,
// then custom tokens like .textPrimary ARE correct — see token-shapestyle-extensions
}
struct SettingsRow: View {
let title: String
let detail: String
var body: some View {
HStack {
Text(title)
.foregroundStyle(.textPrimary) // Custom token = system .primary
Spacer()
Text(detail)
.foregroundStyle(.textSecondary) // Custom token = system .secondary
}
.padding()
.background(.backgroundBase) // Custom token = system background
}
}Correct (system colors first, custom tokens only for gaps):
// Use system colors for standard roles — no custom tokens needed
@Equatable
struct SettingsRow: View {
let title: String
let detail: String
var body: some View {
HStack {
Text(title)
.foregroundStyle(.primary) // Built-in, auto-adapts
Spacer()
Text(detail)
.foregroundStyle(.secondary) // Built-in, auto-adapts
}
.padding()
// No explicit background needed — inherits system background
}
}
// Define custom tokens ONLY for colors that diverge from system defaults
extension ShapeStyle where Self == Color {
// Brand-specific: no system equivalent
static var accentPrimary: Color { Color("accentPrimary") }
static var accentSecondary: Color { Color("accentSecondary") }
// Status colors: system colors are too generic
static var statusSuccess: Color { Color("statusSuccess") }
static var statusWarning: Color { Color("statusWarning") }
static var statusError: Color { Color("statusError") }
// Brand backgrounds: intentionally different from system
static var backgroundBrand: Color { Color("backgroundBrand") }
// Elevated surface with specific brand treatment
static var backgroundElevated: Color { Color("backgroundElevated") }
}System colors available in SwiftUI and when to use them:
| System Color | Purpose | Custom token needed? |
|---|---|---|
.primary | Main text, high emphasis | No — use directly |
.secondary | Supporting text, medium emphasis | No — use directly |
.tertiary | Disabled text, low emphasis | No — use directly |
.quaternary | Barely visible text/fills | No — use directly |
Color(.systemBackground) | Root view background | Usually no |
Color(.secondarySystemBackground) | Grouped table background | Usually no |
Color(.tertiarySystemBackground) | Inner grouped content | Usually no |
Color(.separator) | Standard list separator | No — use directly |
Color(.systemGroupedBackground) | Grouped list background | No — use directly |
.tint | Interactive element color | Set once at app root |
.red, .green, .blue, .orange | Status indicators | Only if brand-specific |
Decision matrix — do I need a custom token?
1. Does a SwiftUI system color serve this purpose?
→ .primary, .secondary, .tertiary for text
→ Color(.systemBackground) variants for surfaces
→ .red/.green/.orange for status
If yes: USE THE SYSTEM COLOR. Done.
2. Does the design intentionally diverge from the system default?
→ Brand-specific accent color: YES, add token
→ Brand-specific background: YES, add token
→ Status color that must match brand guidelines: YES, add token
→ Text color that looks exactly like .primary: NO, use .primary
3. Will this color need dark/light variants different from system?
→ If the system variant already matches both modes: use system
→ If brand requires specific hex values: add custom tokenBenefits:
- Fewer custom tokens to maintain: 6-8 custom tokens + system colors vs. 25+ all-custom
- Automatic adaptation to accessibility settings (high contrast, bold text, increased contrast)
- Platform consistency: your app feels native because it uses native colors
- Future-proof: when Apple updates system colors for new hardware, your app benefits automatically
Reference: Color — Human Interface Guidelines, UIColor System Colors — Apple Developer
Set Brand Color as App Tint, Don't Scatter It
SwiftUI's .tint() modifier cascades through the entire view hierarchy. Setting it once at the app root automatically colors every Button, Toggle, Link, ProgressView, Stepper, Slider, DatePicker, NavigationLink, and TabView icon. When developers manually apply brand color to each interactive element instead, the app accumulates 50+ individual color callsites that can independently drift to the wrong shade, miss updates during a rebrand, or disagree with each other.
Incorrect (brand color applied manually to each interactive element):
struct CheckoutView: View {
@State private var agreeToTerms = false
@State private var quantity = 1
var body: some View {
VStack(spacing: 16) {
Stepper("Quantity: \(quantity)", value: $quantity, in: 1...10)
.tint(Color("brandPurple")) // Manual tint #1
Toggle("I agree to terms", isOn: $agreeToTerms)
.tint(Color("brandPurple")) // Manual tint #2
Button("Place Order") { placeOrder() }
.buttonStyle(.borderedProminent)
.tint(Color("brandPurple")) // Manual tint #3
NavigationLink("View order history") {
OrderHistoryView()
}
.foregroundStyle(Color("brandPurple")) // Manual tint #4 (wrong API)
if isProcessing {
ProgressView()
.tint(Color("brandPurple")) // Manual tint #5
}
}
}
}
// 5 callsites in one view. 40 views = 200 callsites to maintain.
// Developer on another screen uses Color("brandBlue") — now there are two brand colors.Correct (tint set once at app root):
@main
struct ShopApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.tint(.accentPrimary) // Single source of truth for brand color
}
}
}
// Every view inherits the tint automatically — zero manual color application
@Equatable
struct CheckoutView: View {
@State private var agreeToTerms = false
@State private var quantity = 1
var body: some View {
VStack(spacing: Spacing.md) {
Stepper("Quantity: \(quantity)", value: $quantity, in: 1...10)
// Stepper inherits .accentPrimary tint ✓
Toggle("I agree to terms", isOn: $agreeToTerms)
// Toggle inherits .accentPrimary tint ✓
Button("Place Order") { placeOrder() }
.buttonStyle(.borderedProminent)
// Button inherits .accentPrimary tint ✓
NavigationLink("View order history") {
OrderHistoryView()
}
// NavigationLink inherits .accentPrimary tint ✓
if isProcessing {
ProgressView()
// ProgressView inherits .accentPrimary tint ✓
}
}
}
}
// 0 manual tint applications. Brand color changes in 1 place.Overriding tint for specific elements (when genuinely needed):
struct NotificationBanner: View {
let type: BannerType
var body: some View {
HStack(spacing: Spacing.sm) {
Image(systemName: type.iconName)
.foregroundStyle(type.color) // Status color overrides brand tint
Text(type.message)
.foregroundStyle(.textPrimary)
Spacer()
Button("Dismiss") { dismiss() }
// This button still uses the app tint — correct for interactive elements
}
.padding(Spacing.md)
.background(type.color.opacity(0.12))
}
}
enum BannerType {
case success, warning, error
var color: Color {
switch self {
case .success: .statusSuccess // Green — overrides brand tint for status
case .warning: .statusWarning // Amber — overrides brand tint for status
case .error: .statusError // Red — overrides brand tint for status
}
}
var iconName: String {
switch self {
case .success: "checkmark.circle.fill"
case .warning: "exclamationmark.triangle.fill"
case .error: "xmark.circle.fill"
}
}
}Alternative: AccentColor in asset catalog:
// You can also set the global accent color via the asset catalog:
// Assets.xcassets/AccentColor.colorset — Xcode uses this as the default tint
// This works, but .tint() in code is more explicit and discoverable.
// The recommendation is to use BOTH:
// 1. AccentColor in asset catalog (for storyboards, Info.plist references)
// 2. .tint() at app root (for SwiftUI, explicit and greppable)Benefits:
- Rebrand: change 1 line (
.tint(.newBrandColor)), every interactive element updates instantly - Consistency: impossible for one screen to use a different brand shade than another
- Less code: views are shorter and more focused on layout, not color management
- Correct by default: new views automatically get the brand color without any developer action
Reference: tint(_:) — Apple Developer-93mfq), WWDC22 — What's new in SwiftUI
Isolate All Token Definitions in a Dedicated Directory
When color extensions live in Views/ProfileView.swift, spacing constants in Utils/Constants.swift, and button styles in Components/Buttons.swift, no one can answer "what tokens exist?" without searching the entire codebase. A dedicated DesignSystem/ directory (or Swift package) creates a clear boundary: everything inside defines the system, everything outside consumes it.
Incorrect (tokens scattered across the codebase):
Sources/
├── Utils/
│ ├── Constants.swift // Spacing values live here
│ └── Extensions/
│ └── Color+Hex.swift // Color extensions here
├── Views/
│ ├── ProfileView.swift // Contains local Color("brandTeal")
│ └── Components/
│ ├── PrimaryButton.swift // Contains ButtonStyle + hardcoded colors
│ └── Card.swift // Contains local cornerRadius constant
├── Styles/
│ └── TextStyles.swift // Typography definitions somewhere else
└── Resources/
└── Assets.xcassets/ // Asset catalog disconnected from code tokens// Constants.swift — generic dumping ground
enum Constants {
static let defaultPadding: CGFloat = 16
static let apiBaseURL = "https://api.example.com" // Mixed concerns
static let maxRetries = 3
static let cardCornerRadius: CGFloat = 12
}Correct (dedicated DesignSystem directory with clear internal structure):
Sources/
├── DesignSystem/
│ ├── Tokens/
│ │ ├── Colors.swift // Color extensions for asset catalog access
│ │ ├── Spacing.swift // Spacing scale
│ │ ├── Radius.swift // Corner radius scale
│ │ └── Typography.swift // Type scale (if extending beyond system styles)
│ ├── Styles/
│ │ ├── ButtonStyles.swift // PrimaryButtonStyle, SecondaryButtonStyle
│ │ ├── TextFieldStyles.swift // BrandedTextFieldStyle
│ │ └── ToggleStyles.swift // Custom toggle styles
│ ├── Components/
│ │ ├── LoadingIndicator.swift // Branded loading spinner
│ │ └── EmptyStateView.swift // Reusable empty state component
│ └── Extensions/
│ └── View+DesignSystem.swift // Design system view modifiers
├── Features/
│ ├── Profile/
│ │ ├── ProfileView.swift
│ │ └── ProfileViewModel.swift
│ ├── Orders/
│ │ ├── OrderListView.swift
│ │ └── OrderDetailView.swift
│ └── Settings/
│ └── SettingsView.swift
├── Networking/
│ └── APIClient.swift
└── Resources/
├── Colors.xcassets/ // Color assets
├── Images.xcassets/ // Image assets
└── Icons.xcassets/ // Icon assets// DesignSystem/Tokens/Colors.swift — all color tokens in one file
import SwiftUI
extension ShapeStyle where Self == Color {
// MARK: - Background
static var backgroundPrimary: Color { Color("backgroundPrimary") }
static var backgroundSurface: Color { Color("backgroundSurface") }
static var backgroundElevated: Color { Color("backgroundElevated") }
// MARK: - Accent
static var accentPrimary: Color { Color("accentPrimary") }
static var accentSecondary: Color { Color("accentSecondary") }
// MARK: - Status
static var statusSuccess: Color { Color("statusSuccess") }
static var statusWarning: Color { Color("statusWarning") }
static var statusError: Color { Color("statusError") }
}
// DesignSystem/Tokens/Spacing.swift — all spacing tokens in one file
enum Spacing {
static let xxs: CGFloat = 2
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 16
static let lg: CGFloat = 24
static let xl: CGFloat = 32
static let xxl: CGFloat = 48
}
// DesignSystem/Styles/ButtonStyles.swift — all button styles in one file
struct PrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundStyle(.white)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.sm)
.background(.accentPrimary)
.clipShape(RoundedRectangle(cornerRadius: Radius.md))
.opacity(configuration.isPressed ? 0.85 : 1)
}
}For larger apps, consider a Swift package:
MyApp.xcodeproj
Packages/
└── DesignSystem/
├── Package.swift
└── Sources/
└── DesignSystem/
├── Tokens/
├── Styles/
└── Components/A Swift package enforces the boundary at the build system level — feature modules must explicitly import DesignSystem, making the dependency direction visible and preventing circular references.
Use SwiftLint Rules to Enforce Token Usage
Code review catches design token violations when reviewers remember to look for them. SwiftLint catches them every time. After the initial effort of defining tokens, the hardest part is ensuring the team actually uses them. Custom SwiftLint rules turn token compliance from a social contract into an automated gate.
Incorrect (relying solely on manual code review):
// These violations slip through review because they "look fine" and compile cleanly
struct PaymentCard: View {
var body: some View {
VStack(spacing: 12) { // Hardcoded value, not Spacing token
Text("Payment Method")
.font(.system(size: 17, weight: .semibold)) // Hardcoded font, not text style
.foregroundStyle(Color(hex: "#1C1C1E")) // Literal color, not token
cardContent
}
.padding(20) // Hardcoded value
.background(Color(red: 0.96, green: 0.96, blue: 0.97)) // Color literal
.clipShape(RoundedRectangle(cornerRadius: 16)) // Hardcoded radius
}
}Correct (SwiftLint rules flag violations automatically):
# .swiftlint.yml
custom_rules:
# Flag raw numeric padding values — should use Spacing tokens
no_literal_padding:
regex: '\.padding\(\s*\d'
message: "Use Spacing tokens (Spacing.sm, .md, .lg) instead of literal values. See DesignSystem/Tokens/Spacing.swift"
severity: warning
# Flag raw numeric spacing in stacks — should use Spacing tokens
no_literal_spacing:
regex: '(VStack|HStack|LazyVStack|LazyHStack)\(spacing:\s*\d'
message: "Use Spacing tokens instead of literal values for stack spacing"
severity: warning
# Flag Color(hex:), Color(red:), Color(#...) — should use asset catalog tokens
no_color_literal:
regex: 'Color\((hex:|red:\s|#)'
message: "Use semantic color tokens from Colors.xcassets instead of literal colors"
severity: error
# Flag Font.system(size:) outside DesignSystem directory — should use text styles
no_hardcoded_font_size:
regex: 'Font\.system\(size:'
message: "Use system text styles (.headline, .body) or AppTypography tokens instead of hardcoded font sizes"
severity: warning
excluded: "Sources/DesignSystem/.*"
# Flag hardcoded corner radius — should use Radius tokens
no_literal_radius:
regex: 'cornerRadius:\s*\d'
message: "Use Radius tokens (Radius.sm, .md, .lg) instead of literal values"
severity: warning
excluded: "Sources/DesignSystem/.*"
# Flag Color("string") usage — should use typed Color extensions
no_raw_color_string:
regex: 'Color\("'
message: "Use typed Color extensions (.backgroundPrimary) instead of Color(\"string\"). See DesignSystem/Tokens/Colors.swift"
severity: warning
excluded: "Sources/DesignSystem/.*"// After enabling rules, the same code triggers clear warnings:
struct PaymentCard: View {
var body: some View {
VStack(spacing: 12) { // ⚠️ Use Spacing tokens instead of literal values
Text("Payment Method")
.font(.system(size: 17)) // ⚠️ Use system text styles or AppTypography
.foregroundStyle(Color(hex: "#1C1C1E")) // ❌ Use semantic color tokens
cardContent
}
.padding(20) // ⚠️ Use Spacing tokens
.background(Color(red: 0.96, green: 0.96, blue: 0.97)) // ❌ Use semantic color tokens
.clipShape(RoundedRectangle(cornerRadius: 16)) // ⚠️ Use Radius tokens
}
}
// Fixed version — zero violations:
@Equatable
struct PaymentCard: View {
var body: some View {
VStack(spacing: Spacing.sm) {
Text("Payment Method")
.font(.headline)
.foregroundStyle(.labelPrimary)
cardContent
}
.padding(Spacing.lg)
.background(.backgroundSurface)
.clipShape(RoundedRectangle(cornerRadius: Radius.md))
}
}CI integration:
# In CI pipeline (e.g., GitHub Actions)
- name: Lint design system compliance
run: swiftlint lint --reporter github-actions-loggingThe excluded: "Sources/DesignSystem/.*" pattern is critical — token definitions themselves necessarily use raw values. Only feature code should be linted for token compliance.
Migrate to Design Tokens Incrementally, Not All at Once
A big-bang migration that replaces all hardcoded values, restructures the color system, adds typography tokens, and creates component styles in a single PR is unreviewable and untestable. If a visual regression slips in, git blame points to a 2,000-line commit. Incremental migration — one token domain per PR — keeps diffs small, regressions traceable, and lets the team build confidence in the system.
Incorrect (everything in one massive PR):
PR #247: "Implement Design System" — 2,847 lines changed
- Created DesignSystem/ directory
- Added Colors.xcassets with 45 semantic colors
- Created Color extensions
- Created Spacing enum
- Created Radius enum
- Created Typography tokens
- Created ButtonStyles
- Created TextFieldStyles
- Replaced all Color(hex:) calls (127 files)
- Replaced all hardcoded paddings (89 files)
- Replaced all Font.system(size:) calls (64 files)
- Added SwiftLint rules
- Fixed 23 layout issues caused by spacing changes
Reviewer: "I can't review this. LGTM, I guess?"Correct (phased migration, one domain per PR):
// PHASE 1 (Sprint 1): Colors — highest visual impact, easiest to verify
// PR #247: "Add semantic color tokens" — 180 lines
// 1a. Create Colors.xcassets with semantic color sets
// 1b. Create Color extensions (ShapeStyle for dot-syntax)
extension ShapeStyle where Self == Color {
static var backgroundPrimary: Color { Color("backgroundPrimary") }
static var backgroundSurface: Color { Color("backgroundSurface") }
static var labelPrimary: Color { Color("labelPrimary") }
static var accentPrimary: Color { Color("accentPrimary") }
// ... all semantic colors
}
// 1c. Replace all Color(hex:) and Color(red:green:blue:) calls
// Before:
Text(title).foregroundStyle(Color(hex: "#1C1C1E"))
// After:
Text(title).foregroundStyle(.labelPrimary)
// Verification: visual screenshot comparison, zero layout changes expected// PHASE 2 (Sprint 2): Spacing — moderate visual impact
// PR #263: "Add spacing tokens" — 120 lines
enum Spacing {
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 16
static let lg: CGFloat = 24
static let xl: CGFloat = 32
}
// Replace hardcoded padding/spacing values
// Before:
VStack(spacing: 8) { ... }.padding(16)
// After:
VStack(spacing: Spacing.sm) { ... }.padding(Spacing.md)
// Verification: most replacements are 1:1, but audit for edge cases
// where .padding(14) was intentionally different from the scale// PHASE 3 (Sprint 3): Corner radii
// PR #278: "Add radius tokens" — 60 lines
enum Radius {
static let sm: CGFloat = 8
static let md: CGFloat = 12
static let lg: CGFloat = 16
static let xl: CGFloat = 24
static let full: CGFloat = .infinity
}
// Before:
.clipShape(RoundedRectangle(cornerRadius: 12))
// After:
.clipShape(RoundedRectangle(cornerRadius: Radius.md))// PHASE 4 (Sprint 4): Typography — only if custom type scale needed
// PR #291: "Add typography tokens" — 90 lines
// Most apps should use system text styles (.headline, .body, .caption)
// Only add this phase if the brand requires custom font families or sizes// PHASE 5 (Sprint 5): Component styles
// PR #305: "Add shared button and text field styles" — 100 lines
struct PrimaryButtonStyle: ButtonStyle { ... }
struct SecondaryButtonStyle: ButtonStyle { ... }
struct BrandedTextFieldStyle: TextFieldStyle { ... }// PHASE 6 (Sprint 6): Enforcement
// PR #319: "Add SwiftLint rules for token compliance" — 40 lines
// Only enable enforcement AFTER the migration is complete
// Otherwise every existing file triggers warningsMigration tracking template:
| Phase | Domain | Files Changed | PR | Status |
|---|---|---|---|---|
| 1 | Colors | ~130 | #247 | Merged |
| 2 | Spacing | ~90 | #263 | In Review |
| 3 | Radii | ~40 | — | Next Sprint |
| 4 | Typography | ~60 | — | Backlog |
| 5 | Component Styles | ~30 | — | Backlog |
| 6 | Lint Enforcement | 1 | — | After Phase 5 |
Each phase produces a reviewable PR (under 200 lines), has a clear verification method (visual diff for colors, layout diff for spacing), and can be rolled back independently if issues arise.
Enforce Consistent Naming Conventions Across All Tokens
A design system is an API. Its naming conventions determine how fast developers find the right token. Airbnb's Swift Style Guide mandates lowerCamelCase for all properties and PascalCase for types. Design tokens must follow the same conventions: backgroundPrimary not background_primary, IconSize not iconSize for the enum, Spacing.md not Spacing.medium. Consistency across token domains means learning one naming pattern unlocks all of them.
Incorrect (inconsistent naming across token domains):
// Colors: mixed naming strategies
extension ShapeStyle where Self == Color {
static var bg_primary: Color { Color("bg_primary") } // snake_case
static var TextPrimary: Color { Color("TextPrimary") } // PascalCase
static var accent_primary: Color { Color("accent-primary") } // kebab in catalog
}
// Spacing: verbose names
enum Spacing {
static let smallSpacing: CGFloat = 8 // Redundant "Spacing" suffix
static let medium_spacing: CGFloat = 16 // snake_case
static let LARGE: CGFloat = 24 // SCREAMING_CASE
}
// Sizes: no consistent prefix pattern
enum Sizes {
static let avatarSmall: CGFloat = 32
static let sm_icon: CGFloat = 16
static let LargeButton: CGFloat = 48
}Correct (uniform lowerCamelCase, consistent scale names):
// Colors: lowerCamelCase, role-based naming
extension ShapeStyle where Self == Color {
static var backgroundPrimary: Color { Color("backgroundPrimary") }
static var backgroundSurface: Color { Color("backgroundSurface") }
static var textPrimary: Color { Color("textPrimary") }
static var accentPrimary: Color { Color("accentPrimary") }
static var statusSuccess: Color { Color("statusSuccess") }
}
// Spacing: t-shirt sizes, no domain suffix
enum Spacing {
static let xxs: CGFloat = 2
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 16
static let lg: CGFloat = 24
static let xl: CGFloat = 32
}
// Sizes: PascalCase enum, lowerCamelCase t-shirt sizes
enum IconSize {
static let sm: CGFloat = 16
static let md: CGFloat = 24
static let lg: CGFloat = 32
}
enum AvatarSize {
static let sm: CGFloat = 32
static let md: CGFloat = 44
static let lg: CGFloat = 64
}Naming convention reference:
| Domain | Type Name | Property Names | Examples |
|---|---|---|---|
| Colors | ShapeStyle extension | roleContext (lowerCamelCase) | .backgroundPrimary, .statusError |
| Spacing | enum Spacing | xxs/xs/sm/md/lg/xl/xxl | Spacing.md |
| Radius | enum Radius | sm/md/lg/full | Radius.md |
| Icons | enum IconSize | sm/md/lg/xl | IconSize.md |
| Avatars | enum AvatarSize | sm/md/lg/xl | AvatarSize.lg |
| Typography | enum AppTypography | rolePrimary/roleSecondary | AppTypography.headlinePrimary |
| Asset catalog | — | lowerCamelCase for colors, kebab-case for images | backgroundPrimary, onboarding-hero |
Benefits:
- Xcode autocomplete becomes predictable: type
backgroundand see all background tokens - New developers learn one naming pattern that applies to every token domain
- Code review catches violations instantly: any
snake_caseorSCREAMING_CASEin tokens is wrong
Reference: Airbnb Swift Style Guide, Swift API Design Guidelines
Prevent Feature Modules from Defining Local Design Tokens
Shadow tokens start innocently: a developer creates CheckoutColors.cardBackground because they don't know Color.backgroundSurface exists, or they think their feature "temporarily" needs a slightly different value. Within two sprints, the shadow token is referenced by five views and no one remembers it was meant to be temporary. It now diverges from the system without anyone noticing.
Incorrect (feature modules defining their own design constants):
// Features/Checkout/CheckoutConstants.swift — shadow tokens
enum CheckoutLayout {
static let cardPadding: CGFloat = 16 // Duplicates Spacing.md
static let sectionSpacing: CGFloat = 24 // Duplicates Spacing.lg
static let buttonCornerRadius: CGFloat = 12 // Duplicates Radius.md
}
// Features/Checkout/CheckoutColors.swift — shadow color tokens
extension Color {
static let checkoutBackground = Color(hex: "#F5F5F5") // Duplicates backgroundSecondary
static let checkoutAccent = Color(hex: "#14B8A6") // Duplicates accentPrimary
static let checkoutCardBg = Color(hex: "#FFFFFF") // Duplicates backgroundSurface
}
// Features/Profile/ProfileStyles.swift — more shadow tokens
enum ProfileStyles {
static let headerPadding: CGFloat = 16 // Another Spacing.md duplicate
static let avatarRadius: CGFloat = 32 // Local to one component, fine
static let sectionSpacing: CGFloat = 20 // Differs from Spacing.lg (24) — intentional? Bug?
}Correct (feature modules consume system tokens, request new tokens when needed):
// Features/Checkout/CheckoutView.swift — uses system tokens directly
@Equatable
struct CheckoutView: View {
let cart: Cart
var body: some View {
ScrollView {
VStack(spacing: Spacing.lg) { // System token
CartSummaryCard(cart: cart)
PaymentMethodSection()
PlaceOrderButton(total: cart.total)
}
.padding(Spacing.md) // System token
}
.background(.backgroundSecondary) // System token
}
}
@Equatable
struct CartSummaryCard: View {
let cart: Cart
var body: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
ForEach(cart.items) { item in CartItemRow(item: item) }
Divider()
HStack {
Text("Total").font(.headline)
Spacer()
Text(cart.total.formatted(.currency(code: "GBP"))).font(.headline)
}
}
.padding(Spacing.md)
.background(.backgroundSurface)
.clipShape(RoundedRectangle(cornerRadius: Radius.md))
}
}// If a feature needs a value that doesn't exist, add it via PR:
// DesignSystem/Tokens/Spacing.swift (updated via PR #312)
enum Spacing {
static let xxs: CGFloat = 2
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 16
static let lg: CGFloat = 24
static let xl: CGFloat = 32
static let xxl: CGFloat = 48
static let cardGrid: CGFloat = 20 // Added for checkout — approved in design review
}Enforcement strategies (layered):
# 1. SwiftLint — catches most violations automatically
# .swiftlint.yml
custom_rules:
no_local_cgfloat_constants:
regex: 'static let \w+:\s*CGFloat\s*='
included: "Sources/Features/.*"
excluded: "Sources/DesignSystem/.*"
message: "Design tokens must be defined in DesignSystem/Tokens/. If this is a component-specific dimension (not a design token), add a // swiftlint:disable:this comment with justification."
severity: warning
no_local_color_extensions:
regex: 'extension Color \{'
included: "Sources/Features/.*"
message: "Color extensions must be defined in DesignSystem/Tokens/Colors.swift"
severity: error// 2. PR review checklist (CODEOWNERS)
// .github/CODEOWNERS
Sources/DesignSystem/ @design-system-team
// Any change to DesignSystem/ requires approval from the design system maintainers
// This prevents unreviewed token additions// 3. Component-specific dimensions are fine — document them clearly
struct AvatarView: View {
// Component dimension, not a design token — specific to avatar rendering
// Not derived from spacing scale because it matches the avatar image size
private let diameter: CGFloat = 64
var body: some View {
AsyncImage(url: avatarURL) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.backgroundSecondary
}
.frame(width: diameter, height: diameter)
.clipShape(Circle())
}
}The distinction is: design tokens (values that participate in the visual system — spacing, colors, radii, type) belong in DesignSystem/. Component dimensions (avatar size, chart height, map pin offset) that are specific to one component's rendering logic are fine as local constants.
Every Visual Value Has Exactly One Definition Point
When the same value is defined in two places, they will eventually diverge. Someone will update Spacing.md from 16 to 20 but miss CardLayout.padding which is also 16. Now you have two "standard paddings" — one at 20, one stuck at 16. The single-source-of-truth principle means every design value has exactly one canonical definition, and all other references derive from it.
Incorrect (same values defined in multiple places):
// DesignSystem/Tokens/Spacing.swift
enum Spacing {
static let md: CGFloat = 16
}
// Features/Orders/OrderConstants.swift
enum OrderLayout {
static let cardPadding: CGFloat = 16 // Same as Spacing.md — but who knows?
static let sectionSpacing: CGFloat = 24 // Same as Spacing.lg — or is it?
}
// Features/Profile/ProfileConstants.swift
enum ProfileLayout {
static let contentPadding: CGFloat = 16 // Another copy of Spacing.md
static let avatarSize: CGFloat = 64 // Is this a token or truly local?
}
// After refactor: Spacing.md becomes 20, but OrderLayout.cardPadding
// and ProfileLayout.contentPadding stay at 16. Drift begins.Correct (single definition, all references derive from it):
// DesignSystem/Tokens/Spacing.swift — THE single source
enum Spacing {
static let xxs: CGFloat = 2
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 16
static let lg: CGFloat = 24
static let xl: CGFloat = 32
static let xxl: CGFloat = 48
}
// Features/Orders/OrderRow.swift — references Spacing directly
@Equatable
struct OrderRow: View {
let order: Order
var body: some View {
HStack(spacing: Spacing.sm) {
OrderStatusIcon(status: order.status)
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(order.title).font(.headline)
Text(order.date.formatted()).font(.subheadline)
}
}
.padding(Spacing.md) // Uses token directly, not a local copy
}
}
// If a component genuinely needs a unique value, name it descriptively
// and document WHY it differs from the scale
struct AvatarView: View {
let user: User
// This is intentionally NOT a spacing token — it's a component dimension
// tied to the avatar's visual design, not to the spacing scale
private static let diameter: CGFloat = 64
var body: some View {
AsyncImage(url: user.avatarURL) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Image(systemName: "person.crop.circle.fill")
.foregroundStyle(.labelTertiary)
}
.frame(width: Self.diameter, height: Self.diameter)
.clipShape(Circle())
}
}How to audit for duplicates:
# Find CGFloat constants in feature directories that match token values
grep -rn "static let.*CGFloat = \(2\|4\|8\|16\|24\|32\|48\)" Sources/Features/If this grep returns results, those constants should either reference the token or be documented as intentionally distinct component-specific values.
Isolate the Design System as a Local SPM Package
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
A DesignSystem/ directory inside the app target works until someone in a feature module writes Color("backgroundPrimary") directly instead of using the typed .backgroundPrimary extension. A local SPM package makes the design system a real module boundary: feature code must import DesignSystem to access tokens, and the package's internal types are invisible to consumers. This is the build-system enforcement that directory conventions cannot provide.
Incorrect (design system as a directory — no module boundary):
// Features/Profile/ProfileView.swift
// Developer bypasses typed extensions and uses raw string lookup
Text(user.name)
.foregroundStyle(Color("textPrimary")) // Works, but untyped
.padding(16) // Bypasses Spacing tokens
.background(Color("backgroundSurface")) // No compile-time check on nameCorrect (design system as local SPM package — enforced boundary):
MyApp/
├── MyApp.xcodeproj
├── Packages/
│ └── DesignSystem/
│ ├── Package.swift
│ ├── Sources/
│ │ └── DesignSystem/
│ │ ├── Tokens/
│ │ │ ├── Colors.swift // public extensions
│ │ │ ├── Spacing.swift // public enum
│ │ │ └── Radius.swift // public enum
│ │ ├── Styles/
│ │ │ └── ButtonStyles.swift // public styles
│ │ └── Internal/
│ │ └── RawTokens.swift // internal — invisible to consumers
│ └── Resources/
│ └── Colors.xcassets/
└── Sources/
└── Features/
└── Profile/
└── ProfileView.swift // must: import DesignSystem// Packages/DesignSystem/Package.swift
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "DesignSystem",
platforms: [.iOS(.v17)],
products: [
.library(name: "DesignSystem", targets: ["DesignSystem"]),
],
targets: [
.target(
name: "DesignSystem",
resources: [.process("Resources")]
),
]
)// Packages/DesignSystem/Sources/DesignSystem/Tokens/Colors.swift
import SwiftUI
public extension ShapeStyle where Self == Color {
static var backgroundPrimary: Color { Color("backgroundPrimary", bundle: .module) }
static var textPrimary: Color { Color("textPrimary", bundle: .module) }
static var accentPrimary: Color { Color("accentPrimary", bundle: .module) }
}// Features/Profile/ProfileView.swift
import DesignSystem // Explicit dependency — visible in import section
@Equatable
struct ProfileView: View {
let user: User
var body: some View {
Text(user.name)
.foregroundStyle(.textPrimary) // Typed — autocomplete works
.padding(Spacing.md) // Typed — globally updatable
.background(.backgroundSurface) // Typed — compile-time checked
}
}
// Color("textPrimary") without bundle: .module resolves to the wrong catalogBenefits:
internalaccess control hides raw tokens from feature code — impossible to bypass the semantic layer- Explicit
import DesignSystemmakes dependency direction visible in every file - Package boundary prevents circular dependencies between features and design system
bundle: .moduleensures color assets resolve from the package's own catalog
Reference: Organizing Your Code with Local Packages — Apple Developer
Use EdgeInsets Constants for Composite Padding
When a component needs asymmetric padding (different horizontal vs vertical, or different top vs bottom), chaining multiple .padding() calls obscures the intent and invites drift. One developer writes .padding(.horizontal, 16).padding(.vertical, 12), another writes .padding(.top, 12).padding(.bottom, 16).padding(.horizontal, 16) — both for the same component type. EdgeInsets constants encode the full padding as a single named value that's reusable and auditable.
Incorrect (chained padding calls that drift):
// ProductCard.swift — original padding
VStack { /* ... */ }
.padding(.horizontal, 16)
.padding(.vertical, 12)
// ProductCard in SearchResults.swift — copy-pasted, then tweaked
VStack { /* ... */ }
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 16) // "needed more room for the price"
// ProductCard in FavoritesView.swift — yet another variant
VStack { /* ... */ }
.padding(16) // "close enough"
// Three cards, three different internal paddings. No consistency.Correct (EdgeInsets constants with clear naming):
// DesignSystem/Tokens/Insets.swift
extension EdgeInsets {
// Card content — standard internal padding for card-style containers
static let cardContent = EdgeInsets(
top: Spacing.sm,
leading: Spacing.md,
bottom: Spacing.sm,
trailing: Spacing.md
)
// List cell — matches UIKit default cell padding
static let listCell = EdgeInsets(
top: Spacing.sm,
leading: Spacing.md,
bottom: Spacing.sm,
trailing: Spacing.md
)
// Section content — breathing room for section-level containers
static let sectionContent = EdgeInsets(
top: Spacing.md,
leading: Spacing.md,
bottom: Spacing.md,
trailing: Spacing.md
)
// Screen content — outermost content padding
static let screenContent = EdgeInsets(
top: Spacing.md,
leading: Spacing.md,
bottom: Spacing.lg,
trailing: Spacing.md
)
// Banner — taller top/bottom for promotional areas
static let banner = EdgeInsets(
top: Spacing.lg,
leading: Spacing.md,
bottom: Spacing.lg,
trailing: Spacing.md
)
}Usage in views:
// Every ProductCard now has identical padding, guaranteed:
@Equatable
struct ProductCard: View {
let product: Product
var body: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
ProductImage(url: product.imageURL)
Text(product.name)
.font(AppTypography.headlinePrimary)
Text(product.price, format: .currency(code: "GBP"))
.font(AppTypography.bodySecondary)
.foregroundStyle(.secondary)
}
.padding(.cardContent) // Single call, full intent
.background(.backgroundSurface)
.clipShape(RoundedRectangle(cornerRadius: Radius.md))
}
}
// Screen-level padding applied once:
struct ProductListScreen: View {
var body: some View {
ScrollView {
LazyVStack(spacing: Spacing.md) {
ForEach(products) { product in
ProductCard(product: product)
}
}
.padding(.screenContent)
}
}
}Asymmetric insets for specific contexts:
extension EdgeInsets {
// Form field — extra bottom padding for error message space
static let formField = EdgeInsets(
top: Spacing.sm,
leading: Spacing.md,
bottom: Spacing.md, // Room for validation error below
trailing: Spacing.md
)
// Navigation header — more top, tight bottom
static let navigationHeader = EdgeInsets(
top: Spacing.lg,
leading: Spacing.md,
bottom: Spacing.sm,
trailing: Spacing.md
)
}EdgeInsets constants work with the .padding(_:) overload that accepts EdgeInsets directly — no chaining needed.
Zero Hardcoded Numbers in View Layout Code
Every raw number in layout code is an implicit decision that cannot be searched, refactored, or audited. When the design team adjusts the base spacing from 16pt to 14pt, token-based code updates in one place while hardcoded values require a manual sweep across hundreds of files — with no guarantee of completeness. The rule is absolute: if it's a spacing, padding, or sizing value, it must reference a token.
Incorrect (hardcoded values throughout):
struct CheckoutView: View {
var body: some View {
VStack(spacing: 12) {
HStack {
Image(systemName: "cart")
.frame(width: 28, height: 28) // hardcoded
Text("Your Cart")
.padding(.leading, 6) // hardcoded
}
.padding(.horizontal, 20) // hardcoded
.padding(.top, 16) // hardcoded
ForEach(items) { item in
HStack(spacing: 10) { // hardcoded
item.thumbnail
.frame(width: 60, height: 60) // hardcoded
.clipShape(RoundedRectangle(cornerRadius: 8)) // hardcoded
VStack(alignment: .leading, spacing: 4) { // hardcoded
Text(item.name)
Text(item.price)
.padding(.top, 2) // hardcoded
}
}
.padding(.vertical, 8) // hardcoded
}
Button("Checkout") { }
.padding(.horizontal, 32) // hardcoded
.padding(.vertical, 14) // hardcoded
.clipShape(RoundedRectangle(cornerRadius: 12)) // hardcoded
}
.padding(.bottom, 24) // hardcoded
}
}
// 14 hardcoded values. Which are intentional design decisions?
// Which are quick guesses? Impossible to tell.Correct (every value references a token):
@Equatable
struct CheckoutView: View {
var body: some View {
VStack(spacing: Spacing.sm) {
HStack {
Image(systemName: "cart")
.frame(width: IconSize.md, height: IconSize.md)
Text("Your Cart")
.padding(.leading, Spacing.xs)
}
.padding(.horizontal, Spacing.md)
.padding(.top, Spacing.md)
ForEach(items) { item in
HStack(spacing: Spacing.sm) {
item.thumbnail
.frame(width: AvatarSize.md, height: AvatarSize.md)
.clipShape(RoundedRectangle(cornerRadius: Radius.sm))
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(item.name)
Text(item.price)
.padding(.top, Spacing.xxs)
}
}
.padding(.vertical, Spacing.sm)
}
Button("Checkout") { }
.buttonStyle(.primary)
}
.padding(.bottom, Spacing.lg)
}
}
// Every value is traceable to a design decision.
// A spacing audit takes seconds, not hours.Allowed exceptions (not hardcoded values):
// Zero — the absence of spacing is not a hardcoded value
.padding(0)
VStack(spacing: 0)
// Intrinsic dimensions — these describe the element, not spacing
Divider() // 1pt is intrinsic to Divider
.frame(height: 1)
// Geometric ratios — structural, not spacing
.aspectRatio(16/9, contentMode: .fit)
.rotationEffect(.degrees(45))
// Layout priorities — not visual dimensions
.layoutPriority(1)Enforcing the rule in code review:
A quick regex search for raw numbers in SwiftUI layout calls catches violations:
// Regex pattern for PR reviews:
\.(padding|spacing|frame|offset)\(.*\d{2,}.*\)
// Matches .padding(16), .frame(width: 44), etc.
// Should match ZERO lines in committed code.Define Corner Radius Tokens by Component Type
Corner radius is one of the most visible brand signals in an app. When every component picks a slightly different radius — 8pt here, 10pt there, 12pt on another — the result feels unfinished. Defining 3-4 radius tokens grouped by component scale creates visual consistency and eliminates bike-shedding in code review.
Incorrect (inconsistent radii with no system):
// SearchBar.swift
.clipShape(RoundedRectangle(cornerRadius: 10))
// ProductCard.swift
.clipShape(RoundedRectangle(cornerRadius: 12))
// ActionButton.swift
.clipShape(RoundedRectangle(cornerRadius: 8))
// BottomSheet.swift
.clipShape(RoundedRectangle(cornerRadius: 16))
// TagChip.swift
.clipShape(RoundedRectangle(cornerRadius: 6))
// UserAvatar.swift
.clipShape(RoundedRectangle(cornerRadius: 22))
// Six different radii — no visual system.
// "Should this new tooltip be 8 or 10?" becomes a recurring debate.Correct (3-4 radius tokens mapped to component scale):
// DesignSystem/Tokens/Radius.swift
enum Radius {
/// 8pt — small elements: chips, tags, badges, tooltips, text fields
static let sm: CGFloat = 8
/// 12pt — medium elements: cards, buttons, list rows, search bars
static let md: CGFloat = 12
/// 20pt — large containers: sheets, modals, popovers, action sheets
static let lg: CGFloat = 20
/// Fully rounded — pills, avatars, circular buttons, toggles
static let full: CGFloat = .infinity
}
// Usage:
struct ProductCard: View {
var body: some View {
VStack { /* ... */ }
.clipShape(RoundedRectangle(cornerRadius: Radius.md))
}
}
struct TagChip: View {
var body: some View {
Text(tag.name)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, Spacing.xs)
.background(.fill.tertiary)
.clipShape(RoundedRectangle(cornerRadius: Radius.sm))
}
}
struct UserAvatar: View {
var body: some View {
image
.clipShape(RoundedRectangle(cornerRadius: Radius.full))
}
}Convenience shapes for repeated use:
extension RoundedRectangle {
static let cardShape = RoundedRectangle(cornerRadius: Radius.md)
static let chipShape = RoundedRectangle(cornerRadius: Radius.sm)
static let sheetShape = RoundedRectangle(cornerRadius: Radius.lg)
}
// Cleaner callsites:
.clipShape(.cardShape)
.clipShape(.chipShape)How to decide which token a new component gets:
| Component Scale | Token | Examples |
|---|---|---|
| Small (< 44pt height) | .sm | Chips, badges, tags, tooltips |
| Medium (44-200pt) | .md | Cards, buttons, inputs, cells |
| Large (> 200pt) | .lg | Sheets, modals, full-width containers |
| Circular | .full | Avatars, FABs, status indicators |
If a new component doesn't clearly fit, pick the closest scale. Never introduce a fifth radius value.
Define Size Tokens for Common Dimensions
Beyond spacing and radii, apps have recurring dimensional values: icon sizes, avatar sizes, minimum touch targets, thumbnail dimensions, and divider heights. Without tokens, these drift — one screen uses 24pt icons, another uses 20pt, a third uses 28pt. Size tokens lock these dimensions to a defined scale and ensure HIG compliance (e.g., the 44pt minimum touch target).
Incorrect (dimensions as hardcoded values):
// NavigationBar.swift
Image(systemName: "bell")
.frame(width: 22, height: 22)
// ProfileHeader.swift
AsyncImage(url: user.avatarURL)
.frame(width: 80, height: 80)
.clipShape(Circle())
// MessageRow.swift
AsyncImage(url: sender.avatarURL)
.frame(width: 40, height: 40) // Different avatar size, intentional?
.clipShape(Circle())
// SettingsRow.swift
Image(systemName: "gear")
.frame(width: 28, height: 28) // Different icon size than nav bar
// ActionButton.swift
Button { } label: { Image(systemName: "plus") }
.frame(width: 36, height: 36) // Below 44pt minimum touch target!Correct (size tokens by category):
// DesignSystem/Tokens/Size.swift
/// Icon sizes — for SF Symbols and custom icons
enum IconSize {
/// 16pt — inline icons (list accessories, label decorations)
static let sm: CGFloat = 16
/// 24pt — standard icons (toolbar, navigation, list leading)
static let md: CGFloat = 24
/// 32pt — prominent icons (empty states, feature callouts)
static let lg: CGFloat = 32
/// 48pt — hero icons (onboarding, large empty states)
static let xl: CGFloat = 48
}
/// Avatar sizes — for user/entity images
enum AvatarSize {
/// 32pt — compact contexts (comment threads, inline mentions)
static let sm: CGFloat = 32
/// 44pt — standard contexts (list rows, message cells)
static let md: CGFloat = 44
/// 64pt — detail contexts (profile headers, contact cards)
static let lg: CGFloat = 64
/// 96pt — hero contexts (own profile, full-screen headers)
static let xl: CGFloat = 96
}
/// Touch target minimums — Apple HIG compliance
enum HitTarget {
/// 44pt — absolute minimum per Apple HIG
static let minimum: CGFloat = 44
/// 48pt — comfortable target for primary actions
static let comfortable: CGFloat = 48
}
/// Thumbnail sizes — for media previews
enum ThumbnailSize {
/// 60pt — compact grid thumbnails
static let sm: CGFloat = 60
/// 80pt — list row thumbnails
static let md: CGFloat = 80
/// 120pt — featured content thumbnails
static let lg: CGFloat = 120
}Usage in views:
@Equatable
struct MessageRow: View {
let message: Message
var body: some View {
HStack(spacing: Spacing.sm) {
AsyncImage(url: message.sender.avatarURL)
.frame(width: AvatarSize.md, height: AvatarSize.md)
.clipShape(Circle())
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(message.sender.name)
.font(AppTypography.headlinePrimary)
Text(message.preview)
.font(AppTypography.bodySecondary)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
Image(systemName: "chevron.right")
.frame(width: IconSize.sm, height: IconSize.sm)
.foregroundStyle(.tertiary)
}
.padding(.listCell)
}
}
// Touch target compliance:
struct FloatingActionButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "plus")
.font(.title3)
.frame(
width: HitTarget.comfortable,
height: HitTarget.comfortable
)
.background(.accentPrimary)
.foregroundStyle(.white)
.clipShape(Circle())
.shadow(radius: 4, y: 2)
}
}
}Size tokens complement spacing tokens. Together, they eliminate all layout hardcoded values.
Define Spacing Tokens as a Caseless Enum
Hardcoded values are the most common design system violation. Every .padding(16) is a decision that future developers must reverse-engineer: is 16 the standard card padding, or was it a one-off choice? A spacing token enum answers that question by name and makes global spacing adjustments a single-file change. Apple internally uses a 4pt base grid, and your tokens should follow the same principle.
Incorrect (hardcoded values with no system):
struct OrderSummaryView: View {
var body: some View {
VStack(spacing: 12) {
Text(order.title)
.padding(.horizontal, 20)
.padding(.top, 16)
Divider()
.padding(.horizontal, 16) // Wait, why 16 here but 20 above?
ForEach(order.items) { item in
ItemRow(item: item)
.padding(.horizontal, 20) // Back to 20
.padding(.vertical, 8)
}
TotalRow(total: order.total)
.padding(24) // And now 24?
}
// Four different spacing values in one view.
// Which is intentional? Which is a typo?
}
}Correct (spacing tokens with clear intent):
// DesignSystem/Tokens/Spacing.swift
enum Spacing {
/// 2pt — hairline separations, icon-to-label tight pairs
static let xxs: CGFloat = 2
/// 4pt — compact element gaps, inline icon spacing
static let xs: CGFloat = 4
/// 8pt — standard element spacing within a group
static let sm: CGFloat = 8
/// 16pt — standard content padding, section spacing
static let md: CGFloat = 16
/// 24pt — spacing between distinct sections
static let lg: CGFloat = 24
/// 32pt — major section breaks, screen-level spacing
static let xl: CGFloat = 32
/// 48pt — hero spacing, visual breathing room
static let xxl: CGFloat = 48
}
// Usage — clear intent, globally adjustable:
@Equatable
struct OrderSummaryView: View {
var body: some View {
VStack(spacing: Spacing.sm) {
Text(order.title)
.padding(.horizontal, Spacing.md)
.padding(.top, Spacing.md)
Divider()
.padding(.horizontal, Spacing.md)
ForEach(order.items) { item in
ItemRow(item: item)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
TotalRow(total: order.total)
.padding(Spacing.lg)
}
}
}Why a caseless enum (not a struct)?
// Caseless enum cannot be instantiated — it's a pure namespace
enum Spacing {
static let md: CGFloat = 16
}
// Struct CAN be instantiated — confusing, adds nothing
struct Spacing {
static let md: CGFloat = 16
}
let _ = Spacing() // Compiles but is meaninglessScale progression options:
// Geometric (multiply by 2): 4, 8, 16, 32, 64
// Good for: clear visual jumps between levels
// Arithmetic (add 8): 8, 16, 24, 32, 40
// Good for: gradual, subtle progression
// Hybrid (Apple-like): 2, 4, 8, 16, 24, 32, 48
// Good for: practical flexibility at every scaleThe hybrid scale works best for most apps because it provides tight spacing at small sizes (2, 4, 8) and comfortable jumps at larger sizes (16, 24, 32, 48).
Build Accessibility into Style Protocols, Not Individual Views
When accessibility behavior (Dynamic Type support, VoiceOver labels, contrast adjustments) is implemented per-view, every new variant risks missing it. Airbnb's DLS approach builds accessibility directly into the base component or style protocol, so every visual variant inherits correct behavior automatically. A ButtonStyle that reads @Environment(\.dynamicTypeSize) and adjusts layout ensures every button variant supports Dynamic Type without per-variant code.
Incorrect (accessibility added per-variant — inconsistent coverage):
struct LargeActionButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.sm)
.background(.accentPrimary)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Radius.md))
}
// Missing: no Dynamic Type adaptation, no minimum contrast check
}
struct CompactButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.subheadline)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, Spacing.xs)
.background(.accentPrimary)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Radius.sm))
}
// Also missing accessibility — duplicated omission
}Correct (accessibility in the base style, variants inherit it):
struct PrimaryButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.controlSize) private var controlSize
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(font)
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
.frame(maxWidth: dynamicTypeSize.isAccessibilitySize ? .infinity : nil)
.frame(minHeight: HitTarget.minimum)
.background(isEnabled ? .accentPrimary : .fill.tertiary)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Radius.md))
.opacity(configuration.isPressed ? 0.85 : 1.0)
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
}
private var font: Font {
switch controlSize {
case .mini, .small: .subheadline.weight(.semibold)
case .large, .extraLarge: .title3.weight(.semibold)
default: .headline
}
}
private var horizontalPadding: CGFloat {
controlSize == .small ? Spacing.sm : Spacing.lg
}
private var verticalPadding: CGFloat {
controlSize == .small ? Spacing.xs : Spacing.sm
}
}Accessibility checklist for design system styles:
| Requirement | How to Implement |
|---|---|
| Dynamic Type | Read dynamicTypeSize, expand layout at accessibility sizes |
| Minimum touch target | frame(minHeight: HitTarget.minimum) (44pt) |
| Disabled state | Read isEnabled, adjust opacity/color |
| Control size | Read controlSize, scale padding/font accordingly |
| Reduce Motion | Read accessibilityReduceMotion, skip animations |
| High Contrast | Use semantic colors from asset catalog (automatic) |
Benefits:
- Product engineers create new styles without worrying about accessibility — it's built in
- Zero accessibility regressions when adding visual variants
- Accessibility Inspector tests pass for every variant because behavior is centralized
- Consistent with Airbnb's DLS pattern of embedding behavior in the component
Reference: Accessibility — Apple HIG, WWDC23 — Build accessible apps with SwiftUI
Apply @Equatable to Every Design System View
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Design system components appear hundreds of times in a typical app — a CardView in a feed, a Badge in every list row. If these views are not diffable, SwiftUI's reflection-based diffing fails silently and re-evaluates every body on every parent state change. Airbnb mandates @Equatable on every view. For design system components that are instantiated most frequently, this is the single highest-impact performance optimization.
Incorrect (no @Equatable — design system views force full re-evaluation):
struct MetricCard: View {
let title: String
let value: String
let trend: TrendDirection
var body: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: Spacing.xxs) {
Text(value)
.font(.title2.bold())
Image(systemName: trend.iconName)
.foregroundStyle(trend.color)
}
}
.padding(Spacing.md)
.background(.backgroundSurface)
.clipShape(RoundedRectangle(cornerRadius: Radius.md))
}
}
// In a dashboard with 12 MetricCards, changing ONE card's value
// re-evaluates ALL 12 bodies because SwiftUI can't diff themCorrect (@Equatable — only changed views re-evaluate):
@Equatable
struct MetricCard: View {
let title: String
let value: String
let trend: TrendDirection
var body: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: Spacing.xxs) {
Text(value)
.font(.title2.bold())
Image(systemName: trend.iconName)
.foregroundStyle(trend.color)
}
}
.padding(Spacing.md)
.background(.backgroundSurface)
.clipShape(RoundedRectangle(cornerRadius: Radius.md))
}
}
// Now only the MetricCard whose value actually changed re-evaluates.
// The other 11 skip body evaluation entirely.For views with closure properties, use @SkipEquatable:
@Equatable
struct ActionCard: View {
let title: String
let subtitle: String
@SkipEquatable
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(title).font(.headline)
Text(subtitle).font(.subheadline).foregroundStyle(.secondary)
}
.padding(Spacing.md)
.background(.backgroundSurface)
.clipShape(RoundedRectangle(cornerRadius: Radius.md))
}
.buttonStyle(.plain)
}
}Prerequisite: The @Equatable macro requires the `ordo-one/equatable` SPM package. The open-source package uses @EquatableIgnored instead of @SkipEquatable (Airbnb's internal name). Alternatively, manually conform to Equatable and use the .equatable() modifier.
Benefits:
- 15% scroll hitch reduction measured at Airbnb after enforcing @Equatable
- Compile-time guarantee: adding a non-Equatable property without @SkipEquatable fails the build
- Design system components are the highest-leverage targets since they appear most frequently
Reference: Airbnb — Understanding and Improving SwiftUI Performance, ordo-one/equatable
Related skills
FAQ
What does ios-design-system do?
ios-design-system: A skill for development. This provides functionality for development workflows.
When should I use ios-design-system?
When you need to use ios-design-system for development tasks, or when ios-design-system: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
ios-design-system.