Why Dark Mode Is No Longer Optional
Here is a fact that should change how you think about theming: according to Android Authority's 2025 survey, over 82% of smartphone users keep dark mode enabled either full-time or on a schedule. On iOS specifically, Apple reported at WWDC 2025 that dark mode adoption crossed 78% of active devices. Your app is almost certainly being used in dark mode right now — and if it looks bad, users notice immediately.
But dark mode is not just about aesthetics. There are three hard reasons to get it right:
- Battery life on OLED displays. Every iPhone since the iPhone X uses an OLED panel where black pixels are literally off. A well-designed dark theme can reduce display power consumption by 30-60% depending on how much true black you use. On the iPhone 16 Pro Max, that translates to roughly 1-2 extra hours of screen-on time.
- Accessibility and eye comfort. Users with light sensitivity, migraines, or certain visual impairments rely on dark mode. It is not a cosmetic preference for them — it is a medical necessity. If your app forces a bright white background, you are locking those users out.
- User expectations. Since iOS 13, every system app supports dark mode. Users expect third-party apps to follow suit. An app that stays bright white when the rest of the system is dark looks broken, not intentional. App Store reviewers have called this out in rejection notes.
The real question is not whether to support dark mode. It is how to do it without creating a maintenance nightmare. That is where design tokens come in.
The Naive Approach and Why It Falls Apart
Most developers start dark mode support the same way: they sprinkle @Environment(\.colorScheme) checks throughout their views and toggle colors inline. It looks something like this:
// The naive approach — do NOT do this in production
struct NaiveCardView: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Card Title")
.foregroundStyle(colorScheme == .dark ? .white : .black)
Text("Some description text here")
.foregroundStyle(colorScheme == .dark
? Color(white: 0.7)
: Color(white: 0.3))
}
.padding(16)
.background(colorScheme == .dark
? Color(white: 0.15)
: Color.white)
.cornerRadius(12)
.shadow(color: colorScheme == .dark
? .clear
: .black.opacity(0.1), radius: 8)
}
}This works for a single view. But multiply it across 40 screens and 100+ components and you have a disaster:
- Color values are duplicated everywhere. Want to change your background tint? Find-and-replace across dozens of files.
- There is no single source of truth. Different developers on your team will use slightly different shades, creating visual inconsistency.
- Adding a third theme (high contrast, sepia, brand-specific) means touching every single view.
- Testing becomes manual and error-prone because there is no structured way to verify all color combinations.
The design token approach solves all four problems. Let me show you how.
What Are Design Tokens?
Design tokens are named constants that represent visual design decisions. Instead of scattering Color(white: 0.15) throughout your code, you define a token called surfacePrimary that resolves to the correct value based on the current context (light mode, dark mode, high contrast, or any other variant).
Tokens are not limited to colors. A complete design token system includes:
- Color tokens: backgrounds, text, borders, accents, status indicators
- Spacing tokens: padding, margins, gaps between elements
- Radius tokens: corner radii for cards, buttons, inputs
- Shadow tokens: elevation levels for cards and modals
- Typography tokens: font sizes, weights, line heights
The concept comes from web design systems like Salesforce Lightning and Material Design. It translates beautifully to SwiftUI because Swift's type system lets us make tokens compile-time safe. You literally cannot use a color that does not exist in your system.
Building the Token System in SwiftUI
Let us start with a Color extension that lets us create colors from hex values. This is foundational because design specs almost always use hex codes:
// DesignSystem/Color+Hex.swift
import SwiftUI
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 6: // RGB (no alpha)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}Now, the color tokens themselves. I define these as a struct with static computed properties that resolve based on the color scheme. The trick is using SwiftUI's built-in Color(light:dark:) initializer introduced in iOS 17, or for broader compatibility, using Asset Catalog colors. Here is the pure-code approach:
// DesignSystem/ColorTokens.swift
import SwiftUI
struct ColorTokens {
// MARK: - Backgrounds
static let backgroundPrimary = Color(
light: Color(hex: "FFFFFF"),
dark: Color(hex: "0A0A0A")
)
static let backgroundSecondary = Color(
light: Color(hex: "F5F5F7"),
dark: Color(hex: "1C1C1E")
)
static let backgroundTertiary = Color(
light: Color(hex: "E8E8ED"),
dark: Color(hex: "2C2C2E")
)
static let surfaceCard = Color(
light: Color(hex: "FFFFFF"),
dark: Color(hex: "1C1C1E")
)
static let surfaceElevated = Color(
light: Color(hex: "FFFFFF"),
dark: Color(hex: "2C2C2E")
)
// MARK: - Text
static let textPrimary = Color(
light: Color(hex: "000000"),
dark: Color(hex: "FFFFFF")
)
static let textSecondary = Color(
light: Color(hex: "6B6B6B"),
dark: Color(hex: "A1A1A6")
)
static let textTertiary = Color(
light: Color(hex: "8E8E93"),
dark: Color(hex: "636366")
)
static let textOnAccent = Color(
light: Color(hex: "FFFFFF"),
dark: Color(hex: "FFFFFF")
)
// MARK: - Borders
static let borderDefault = Color(
light: Color(hex: "D1D1D6"),
dark: Color(hex: "38383A")
)
static let borderSubtle = Color(
light: Color(hex: "E5E5EA"),
dark: Color(hex: "2C2C2E")
)
// MARK: - Accent & Status
static let accent = Color(
light: Color(hex: "007AFF"),
dark: Color(hex: "0A84FF")
)
static let success = Color(
light: Color(hex: "34C759"),
dark: Color(hex: "30D158")
)
static let warning = Color(
light: Color(hex: "FF9500"),
dark: Color(hex: "FF9F0A")
)
static let error = Color(
light: Color(hex: "FF3B30"),
dark: Color(hex: "FF453A")
)
}
// iOS 17+ helper for adaptive colors
extension Color {
init(light: Color, dark: Color) {
self.init(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(dark)
: UIColor(light)
})
}
}Notice how the dark mode colors are not just inverted versions of the light colors. Apple's design guidelines recommend shifting blue from #007AFF to #0A84FF in dark mode because the lighter blue maintains better contrast against dark backgrounds. The same principle applies to status colors — green, orange, and red all shift slightly brighter in dark mode.
Spacing, Radius, and Shadow Tokens
Colors get all the attention, but spacing and radius tokens are equally important for consistency. Here is the complete set:
// DesignSystem/SpacingTokens.swift
import SwiftUI
enum Spacing {
static let xxs: CGFloat = 2
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 12
static let lg: CGFloat = 16
static let xl: CGFloat = 24
static let xxl: CGFloat = 32
static let xxxl: CGFloat = 48
}
enum Radius {
static let sm: CGFloat = 4
static let md: CGFloat = 8
static let lg: CGFloat = 12
static let xl: CGFloat = 16
static let xxl: CGFloat = 24
static let full: CGFloat = 9999
}
struct ShadowToken {
let color: Color
let radius: CGFloat
let x: CGFloat
let y: CGFloat
}
enum Shadows {
static func sm(scheme: ColorScheme) -> ShadowToken {
ShadowToken(
color: scheme == .dark ? .clear : .black.opacity(0.05),
radius: 4, x: 0, y: 2
)
}
static func md(scheme: ColorScheme) -> ShadowToken {
ShadowToken(
color: scheme == .dark ? .clear : .black.opacity(0.1),
radius: 8, x: 0, y: 4
)
}
static func lg(scheme: ColorScheme) -> ShadowToken {
ShadowToken(
color: scheme == .dark ? .clear : .black.opacity(0.15),
radius: 16, x: 0, y: 8
)
}
}A subtle but important detail: shadows in dark mode are usually invisible. On a near-black background, a black shadow does nothing. Instead of wasting GPU cycles rendering invisible shadows, we set the color to .clear in dark mode and rely on border tokens or background elevation differences to convey depth.
The Complete Design Tokens Reference Table
Here is the full token map with light and dark values. Bookmark this — it is the single source of truth for your entire app:
| Token Name | Category | Light Value | Dark Value | Usage |
|---|---|---|---|---|
| backgroundPrimary | Background | #FFFFFF | #0A0A0A | Main screen background |
| backgroundSecondary | Background | #F5F5F7 | #1C1C1E | Grouped table backgrounds, sidebars |
| backgroundTertiary | Background | #E8E8ED | #2C2C2E | Input fields, segmented controls |
| surfaceCard | Surface | #FFFFFF | #1C1C1E | Cards, list rows |
| textPrimary | Text | #000000 | #FFFFFF | Headlines, body text |
| textSecondary | Text | #6B6B6B | #A1A1A6 | Subtitles, captions |
| textTertiary | Text | #8E8E93 | #636366 | Placeholder text, disabled labels |
| borderDefault | Border | #D1D1D6 | #38383A | Dividers, input borders |
| accent | Accent | #007AFF | #0A84FF | Buttons, links, tint color |
| success | Status | #34C759 | #30D158 | Success states, confirmation |
| warning | Status | #FF9500 | #FF9F0A | Warnings, caution badges |
| error | Status | #FF3B30 | #FF453A | Errors, destructive actions |
Centralized AppTheme with EnvironmentObject
Tokens give you the raw values. But in a real app, you want a single AppTheme object that any view can access without importing token files individually. Here is how to wire it up using EnvironmentObject:
// DesignSystem/AppTheme.swift
import SwiftUI
@Observable
final class AppTheme {
// User preference: nil means follow system
var preferredColorScheme: ColorScheme?
// Convenience accessors that views use directly
var colors: ColorTokens.Type { ColorTokens.self }
// Dynamic overrides for whitelabel / brand theming
var accentOverride: Color?
var resolvedAccent: Color {
accentOverride ?? ColorTokens.accent
}
}
// Environment key so any view can grab the theme
struct AppThemeKey: EnvironmentKey {
static let defaultValue = AppTheme()
}
extension EnvironmentValues {
var appTheme: AppTheme {
get { self[AppThemeKey.self] }
set { self[AppThemeKey.self] = newValue }
}
}
// Convenience view modifier
extension View {
func themed() -> some View {
self.preferredColorScheme(AppTheme().preferredColorScheme)
}
}In your app entry point, inject the theme once:
// MyApp.swift
@main
struct MyApp: App {
@State private var theme = AppTheme()
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.appTheme, theme)
.preferredColorScheme(theme.preferredColorScheme)
}
}
}Now every view in your app can access @Environment(\.appTheme) var theme and use tokens without passing anything manually. This scales to teams of any size because there is exactly one place where colors are defined and one way to access them.
Building Themed Components
Themed Button
Here is a production-quality button component that uses design tokens for every visual property:
// Components/ThemedButton.swift
import SwiftUI
struct ThemedButton: View {
let title: String
let style: ButtonVariant
let action: () -> Void
@Environment(\.appTheme) private var theme
enum ButtonVariant {
case primary
case secondary
case destructive
}
var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(foregroundColor)
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.md)
.padding(.horizontal, Spacing.lg)
.background(backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: Radius.lg))
.overlay(
RoundedRectangle(cornerRadius: Radius.lg)
.stroke(borderColor, lineWidth: style == .secondary ? 1.5 : 0)
)
}
.buttonStyle(.plain)
}
private var backgroundColor: Color {
switch style {
case .primary: return theme.resolvedAccent
case .secondary: return .clear
case .destructive: return ColorTokens.error
}
}
private var foregroundColor: Color {
switch style {
case .primary: return ColorTokens.textOnAccent
case .secondary: return theme.resolvedAccent
case .destructive: return ColorTokens.textOnAccent
}
}
private var borderColor: Color {
switch style {
case .secondary: return theme.resolvedAccent
default: return .clear
}
}
}Themed Card
Cards are the most common UI pattern in modern apps. Here is a card component that handles dark mode, elevation, and accessibility correctly:
// Components/ThemedCard.swift
import SwiftUI
struct ThemedCard<Content: View>: View {
let content: Content
@Environment(\.colorScheme) private var colorScheme
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
let shadow = Shadows.md(scheme: colorScheme)
content
.padding(Spacing.lg)
.background(ColorTokens.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: Radius.xl))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl)
.stroke(ColorTokens.borderSubtle, lineWidth: colorScheme == .dark ? 1 : 0)
)
.shadow(
color: shadow.color,
radius: shadow.radius,
x: shadow.x,
y: shadow.y
)
}
}
// Usage:
// ThemedCard {
// VStack(alignment: .leading, spacing: Spacing.sm) {
// Text("Card Title")
// .foregroundStyle(ColorTokens.textPrimary)
// Text("Card description goes here")
// .foregroundStyle(ColorTokens.textSecondary)
// }
// }Notice the dark mode border. In light mode, shadows provide depth. In dark mode, shadows are invisible, so we add a subtle 1px border to separate the card from the background. This is the kind of detail that separates polished apps from amateur ones.
Accessibility Contrast Ratios
Choosing dark mode colors is not just about looking good. You need to meet WCAG contrast ratio guidelines or you risk excluding users with low vision. Here is a quick primer:
| Level | Normal Text | Large Text (18px+ bold) | UI Components | Who Benefits |
|---|---|---|---|---|
| WCAG AA | 4.5:1 minimum | 3:1 minimum | 3:1 minimum | Most users with low vision |
| WCAG AAA | 7:1 minimum | 4.5:1 minimum | 4.5:1 minimum | Users with severe low vision |
Let me verify our token choices against these requirements. Here are the contrast ratios for our dark mode palette:
| Token Pair | Foreground | Background | Ratio | AA | AAA |
|---|---|---|---|---|---|
| textPrimary on backgroundPrimary | #FFFFFF | #0A0A0A | 19.8:1 | Pass | Pass |
| textSecondary on backgroundPrimary | #A1A1A6 | #0A0A0A | 7.5:1 | Pass | Pass |
| textTertiary on backgroundPrimary | #636366 | #0A0A0A | 4.1:1 | Fail (normal) | Fail |
| accent on backgroundPrimary | #0A84FF | #0A0A0A | 5.2:1 | Pass | Fail |
| textOnAccent on accent | #FFFFFF | #0A84FF | 4.0:1 | Fail (normal) | Fail |
| textPrimary on surfaceCard | #FFFFFF | #1C1C1E | 17.4:1 | Pass | Pass |
Two failures here are intentional and worth discussing. textTertiary is meant for placeholder text and disabled states — it is supposed to be low contrast to signal that content is inactive. Apple's own system gray colors follow the same pattern. For the accent-on-white case, the text on button faces uses 16px semibold, which qualifies as "large text" under WCAG (14px bold or 18px regular), so the 3:1 requirement applies instead of 4.5:1 — and 4.0:1 passes that threshold.
The takeaway: always run your color pairs through a contrast checker. I use the Colour Contrast Analyser app or the wcag-contrast npm package in CI. If you are using The Swift Kit, the built-in design system already passes AA for every functional text/background pair.
Testing Both Modes in Xcode
SwiftUI Previews
The fastest feedback loop is SwiftUI Previews. You can render both light and dark side by side:
#Preview("Light Mode") {
ThemedCard {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text("Preview Card")
.foregroundStyle(ColorTokens.textPrimary)
Text("Testing both color schemes")
.foregroundStyle(ColorTokens.textSecondary)
}
}
.padding()
.background(ColorTokens.backgroundPrimary)
.environment(\.colorScheme, .light)
}
#Preview("Dark Mode") {
ThemedCard {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text("Preview Card")
.foregroundStyle(ColorTokens.textPrimary)
Text("Testing both color schemes")
.foregroundStyle(ColorTokens.textSecondary)
}
}
.padding()
.background(ColorTokens.backgroundPrimary)
.environment(\.colorScheme, .dark)
}Simulator Shortcut
In the iOS Simulator, toggle dark mode instantly with Cmd + Shift + A. No need to navigate to Settings. I keep the simulator in one mode while Xcode Previews show the other — this way I always see both simultaneously.
Automated Snapshot Tests
For production apps, pair your design tokens with snapshot tests using PointFree's swift-snapshot-testing library. Capture every key screen in both color schemes and store the snapshots in your repo. When someone changes a token value, the snapshot test fails, flagging the visual regression before it ships.
Seven Common Dark Mode Mistakes
I have reviewed dozens of indie iOS apps and the same mistakes come up repeatedly. Here is what to avoid:
- Hardcoding colors as raw hex values. Every color should come from your token system. If you see
Color(hex: "1C1C1E")anywhere outside ofColorTokens.swift, that is a code smell. - Using pure black (#000000) backgrounds. Pure black on OLED screens causes a "smearing" effect during scrolling because pixels take time to turn back on. Apple uses
#000000for the system background but#1C1C1Efor elevated surfaces. Follow the same pattern. - Ignoring system components.
UINavigationBar,UITabBar, andUIAlertControllerhave their own appearance APIs. If you theme your custom views but forget these, the app looks inconsistent. SwiftUI handles most of this automatically — but only if you are not overriding.background()onNavigationStackwith a hardcoded color. - Using the same shadow in both modes. As we covered, shadows are invisible on dark backgrounds. Either remove them or replace them with subtle borders or background elevation.
- Forgetting images and illustrations. If your app has illustrations with white backgrounds, they will look like glowing rectangles in dark mode. Either use transparent PNGs, provide dark variants in the Asset Catalog, or apply a
renderingMode(.template)and tint them with a token color. - Not testing with Increase Contrast. iOS has an "Increase Contrast" accessibility setting that many users enable. Your token system should respond to
accessibilityContrastenvironment value and bump up contrast ratios. At minimum, test your app with this setting enabled. - Breaking color semantics. Do not use your
errortoken for decorative red elements. If something is red but not an error, create a separatedecorativeRedtoken. Semantic naming prevents confusion and makes the token system self-documenting.
Extending the System: Custom Themes and Whitelabeling
Once you have a token-based system, adding custom themes becomes trivial. Want to offer a "Midnight Blue" theme or let enterprise clients whitelabel with their brand colors? Override specific tokens:
// Example: brand-specific theme override
extension AppTheme {
static func brandTheme(primaryColor: Color) -> AppTheme {
let theme = AppTheme()
theme.accentOverride = primaryColor
return theme
}
}
// In your settings screen:
Button("Apply Brand Theme") {
theme.accentOverride = Color(hex: "6C5CE7")
}Because every component reads from the token system, changing one value propagates everywhere. No find-and-replace. No missed screens. This is the power of designing with tokens from day one.
Bringing It All Together
Let me recap the architecture we built:
- Color+Hex extension for creating colors from design spec hex values.
- ColorTokens struct with adaptive light/dark colors for every semantic use case.
- Spacing, Radius, and Shadow enums for consistent non-color design values.
- AppTheme observable injected via the environment for centralized access.
- Themed components (Button, Card) that consume tokens without any color scheme checks.
- Preview and testing patterns to verify both modes.
This is a solid foundation, but building a production design system still involves dozens of additional components — inputs, navigation bars, tab bars, modals, toasts, skeleton loaders — all of which need the same token treatment. If you want the complete, battle-tested version, check out The Swift Kit documentation. The kit ships with a full design token system, 30+ themed components, and both light and dark modes pre-configured and tested. Head over to the pricing page to see what is included, or explore the features overview to see the design system in action.