Dark Mode shipped with iOS 13 in 2019. Six years later, most apps still implement it badly: weak contrast, screens stuck in light mode, hard-coded colors everywhere.
All 12 of my iOS apps support Dark Mode and user feedback on it has been positive. This is how I approach it, from design through implementation.
Why Dark Mode?
Dark Mode isn’t just aesthetic:
- Battery savings. Dark pixels on OLED screens save 30 to 60% power.
- Eye comfort. Reduced eye strain during night use.
- System consistency. iOS has system-wide dark mode; an app that doesn’t follow it feels jarring.
- Accessibility. Some visual impairments are easier on dark backgrounds.
60 to 70% of the user base has dark mode preference active. Not supporting it isn’t an option for a modern app.
Design-first approach
The dark mode color palette is the designer’s main responsibility. The developer takes the palette and implements it.
Bad approach: “Let’s invert the light mode colors.” White becomes black, black becomes white. It doesn’t work.
A real dark mode palette looks like:
Backgrounds:
- Not pure black (#000000). Too harsh.
- Dark grays: #121212 (Google Material), #1A1A1A, #1F1F1F
- Multiple levels: primary background, secondary (card), tertiary (sub-card)
Text:
- Pure white (#FFFFFF) rarely. Eye strain.
- Off-white: #FAFAFA, #E0E0E0
- Muted: #8A8A8A for secondary info
Accents:
- Desaturate brand colors. Neon on dark is eye-burning.
- Example: brand blue #007AFF becomes dark blue #4DA3FF
Semantic color system
Don’t use colors by their literal name. Use semantic names.
Bad:
Color(hex: "#333333") // What color is this?
Color.darkGray // In what context?Good:
Color("Background") // Light/dark variants in the asset catalog
Color("TextPrimary")
Color("TextSecondary")
Color("Accent")
Color("CardBackground")In the asset catalog, each color gets Any Appearance plus Dark Appearance.
// Asset Catalog > New Color Set
Background:
Any Appearance: #FFFFFF
Dark Appearance: #1A1A1AIn SwiftUI the switch happens automatically.
System colors vs custom
iOS system colors have built-in dark mode support:
Color(.label) // text (dark gray or light gray)
Color(.secondaryLabel)
Color(.systemBackground) // app background
Color(.secondarySystemBackground)
Color(.systemFill)System colors give you platform consistency. Custom colors carry brand identity.
My pattern: primary action/accent custom, structural elements system.
SwiftUI implementation
SwiftUI supports dark mode by default:
struct ContentView: View {
var body: some View {
VStack {
Text("Hello")
.foregroundColor(Color("TextPrimary"))
.padding()
.background(Color("CardBackground"))
}
.background(Color("Background"))
}
}No special code. The system detects the color scheme.
Explicit color scheme (override):
.preferredColorScheme(.dark) // Force darkRarely used. Respect the system preference.
UIKit implementation
UIKit is a bit more manual:
view.backgroundColor = UIColor(named: "Background")
label.textColor = UIColor(named: "TextPrimary")Dynamic colors:
let adaptiveColor = UIColor { traits in
traits.userInterfaceStyle == .dark ? .systemGray : .black
}Trait collection change detection:
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
// Color scheme changed, redraw
}
}Some UIKit custom drawing code needs explicit trait checks.
Image assets
Images also need dark variants.
Asset catalog image:
MyIcon.imageset/
- MyIcon.png (Any Appearance)
- MyIcon-dark.png (Dark Appearance)
- Contents.jsonAssign the dark variant per image in the Xcode GUI. SwiftUI switches automatically.
Alternative: SF Symbols. Always adaptive. Preferred.
Alternative: single image plus blend mode:
Image("icon")
.renderingMode(.template)
.foregroundColor(Color("IconTint"))If the icon is monochrome, tint color handles the adaptation.
Common mistakes
1. Hard-coded colors. Color(.red), Color(hex: "#FFF"). They look wrong in dark mode.
Fix: always asset catalog colors.
2. Shadow opacity. Black shadows look good in light mode. Invisible in dark mode.
Fix:
.shadow(color: Color("ShadowColor"), radius: 4)
// ShadowColor asset: light #00000020, dark #000000803. White backgrounds baked into images. PNGs exported with a white background leave an ugly white patch in dark mode.
Fix: transparent background. Or dark variant image.
4. Accent color mismatch. Brand colors may need different tones in each mode. Saturated is fine in light mode; desaturate in dark.
5. Status bar. Dark mode expects light content in the status bar. Handled automatically, except in custom navigation controllers where it’s often missed.
Testing strategy
1. Simulator dark mode toggle:
Cmd+Shift+A (simulator shortcut)Instant switch. Test every screen in both modes.
2. Xcode preview:
#Preview("Light") {
ContentView()
}
#Preview("Dark") {
ContentView()
.preferredColorScheme(.dark)
}Side-by-side preview. Instant feedback during development.
3. Real device testing. OLED black looks perfect in the simulator, different on real hardware. Periodic real device checks.
4. Dynamic switch test. App open, toggle dark mode from Control Center, watch what the app does. Smooth transition or glitch?
Relationship with accessibility
Dark mode doesn’t stand alone, it interacts with accessibility:
- Reduce Motion: respect it in dark mode transition animations.
- Increase Contrast: bump the contrast further in dark mode.
- Reduce Transparency: handle transparent backgrounds in dark mode.
Check all of them on the trait collection:
if traitCollection.userInterfaceStyle == .dark {
if traitCollection.accessibilityContrast == .high {
// High contrast dark mode
}
}Custom theme support
Some apps offer more than light/dark: “Sepia”, “Ocean Blue”, “Auto”.
Implementation:
enum AppTheme {
case system, light, dark, sepia, ocean
}
@AppStorage("userTheme") var theme: AppTheme = .system
var preferredColorScheme: ColorScheme? {
switch theme {
case .system: return nil
case .light: return .light
case .dark: return .dark
case .sepia, .ocean: return nil // Custom color scheme applied via colors
}
}Sepia needs an entirely separate color asset set. Complex, but valuable for certain apps.
Design review checklist
In a PR review for a dark mode implementation:
- [ ] Has every screen been tested in both light and dark?
- [ ] Any hard-coded colors?
- [ ] Do images have a dark variant (or use SF Symbols)?
- [ ] Are shadows visible in both modes?
- [ ] Does text contrast pass WCAG AA (4.5:1)?
- [ ] Is the accent color properly saturated in dark mode?
- [ ] Are borders and separators visible in both modes?
This checklist catches 90% of dark mode issues.
Takeaway
Dark mode implementation takes discipline. Asset catalog color system, semantic naming, thorough testing.
The first time you implement it, it’s a week of work. On later apps the pattern is established and setup is quick.
Collaboration with the designer is critical. Not the developer’s palette, the designer’s palette, implemented faithfully. Quality comes out of that collaboration.