How to Implement Dark Mode in SwiftUI
Attach .preferredColorScheme(_:) to your root view and drive it from an @AppStorage string — passing nil follows the device setting, while .dark or .light locks the appearance. Changes take effect instantly without restarting the app.
@main
struct MyApp: App {
@AppStorage("appColorScheme") var scheme: String = "system"
var resolvedScheme: ColorScheme? {
switch scheme {
case "dark": return .dark
case "light": return .light
default: return nil // follow device setting
}
}
var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(resolvedScheme)
}
}
}
Full implementation
The pattern below stores the user's preference as a plain String in UserDefaults via @AppStorage, converts it to a typed AppColorScheme enum, and feeds the resolved ColorScheme? directly into .preferredColorScheme(_:) at the root. A segmented Picker in the UI lets the user switch modes live; because both the App struct and ContentView share the same @AppStorage key, the change propagates immediately across the hierarchy without any extra state plumbing.
import SwiftUI
// MARK: - Typed preference enum
enum AppColorScheme: String, CaseIterable, Identifiable {
case system, light, dark
var id: String { rawValue }
var label: String {
switch self {
case .system: return "System"
case .light: return "Light"
case .dark: return "Dark"
}
}
/// nil tells SwiftUI to follow the device setting
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}
// MARK: - App entry point
@main
struct DarkModeApp: App {
@AppStorage("appColorScheme") private var schemePref = AppColorScheme.system.rawValue
private var resolved: ColorScheme? {
AppColorScheme(rawValue: schemePref)?.colorScheme
}
var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(resolved)
}
}
}
// MARK: - Demo content view
struct ContentView: View {
@AppStorage("appColorScheme") private var schemePref = AppColorScheme.system.rawValue
@Environment(\.colorScheme) private var colorScheme
var body: some View {
NavigationStack {
VStack(spacing: 32) {
Spacer()
Image(systemName: colorScheme == .dark ? "moon.stars.fill" : "sun.max.fill")
.font(.system(size: 72))
.foregroundStyle(colorScheme == .dark ? .indigo : .orange)
.symbolEffect(.bounce, value: schemePref)
.accessibilityLabel(colorScheme == .dark ? "Dark mode active" : "Light mode active")
Text("Appearance")
.font(.title2.bold())
Text("Choose how \(Bundle.main.displayName ?? "this app") looks.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Picker("Color scheme", selection: $schemePref) {
ForEach(AppColorScheme.allCases) { scheme in
Text(scheme.label).tag(scheme.rawValue)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 32)
.accessibilityLabel("Select color scheme")
Spacer()
}
.navigationTitle("Display")
.navigationBarTitleDisplayMode(.inline)
}
}
}
// MARK: - Bundle helper
private extension Bundle {
var displayName: String? { object(forInfoDictionaryKey: "CFBundleDisplayName") as? String }
}
// MARK: - Previews
#Preview("Light mode") {
ContentView()
.preferredColorScheme(.light)
}
#Preview("Dark mode") {
ContentView()
.preferredColorScheme(.dark)
}
How it works
-
AppColorSchemeenum — wraps the three possible states (system,light,dark) in a type-safe way and exposes acolorScheme: ColorScheme?computed property. Returningnilfor.systemis the key detail: SwiftUI interprets anilargument to.preferredColorSchemeas "defer to the OS", which is exactly the System option's behaviour. -
@AppStorage("appColorScheme")— persists the raw string toUserDefaultsso the preference survives cold launches. Both theAppstruct andContentViewdeclare the property with the same key, so writing from the Picker in the view automatically triggers a re-render of the root modifier. -
.preferredColorScheme(resolved)onWindowGroup's root — placing this modifier at the top of the hierarchy means every child view inherits the override, including sheets, full-screen covers, and navigation destinations. You don't need to repeat it in sub-views. -
@Environment(\.colorScheme)inContentView— reads the effective scheme after the override is applied, which lets the view reactively swap the icon (moon.stars.fillvssun.max.fill) and its tint colour. This value may differ from the device's own setting when the user has chosen Light or Dark explicitly. -
.symbolEffect(.bounce, value: schemePref)— triggers a subtle bounce animation on the SF Symbol wheneverschemePrefchanges, giving the user tactile feedback that the switch worked. This API is available from iOS 17+ and requires no extra imports.
Variants
Per-view dark-mode override (without changing the whole app)
If you only want to force dark mode for a single sheet or modal — say, a video player — apply the modifier to that view alone instead of the root:
struct PlayerView: View {
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VideoControlsOverlay()
}
// Forces this subtree to always render in dark,
// regardless of the user's app-wide preference.
.preferredColorScheme(.dark)
}
}
#Preview {
PlayerView()
}
Programmatic toggle (no Picker UI)
If you want a single tap-to-toggle button rather than a three-option picker, maintain a Bool via @AppStorage("isDarkMode") and map it directly: .preferredColorScheme(isDarkMode ? .dark : .light). This drops the "follow system" option but is simpler for minimal UIs. Pair it with a Toggle bound to the same key for a Settings-style row.
Common pitfalls
-
iOS 16 and below:
.preferredColorScheme(_:)exists back to iOS 13, but.symbolEffectrequires iOS 17. Wrap it in an#availablecheck or simply remove it if you need a lower deployment target — the rest of the implementation runs fine. -
Applying the modifier too low in the hierarchy: If you attach
.preferredColorSchemeto a child view instead of the root, sheets and navigation destinations that are presented above that view in the hierarchy will not inherit the override and will revert to the system default. Always attach it to theWindowGroup's root view. -
Custom colours ignoring the scheme: Hard-coded
Color(hex:)values and asset catalog colours that lack both Light and Dark variants will not adapt automatically. Use semantic colours (.primary,.secondary,.background) or define both variants in your asset catalog so the system can swap them correctly when the scheme changes. -
Accessibility — respect user intent: If the user has enabled Increase Contrast or Smart Invert in Accessibility settings, forcing
.lightor.darkcan override those accommodations. Offer the "System" option as the default so assistive settings are honoured out of the box.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement dark mode in SwiftUI for iOS 17+. Use preferredColorScheme and @AppStorage to persist the preference. Expose a segmented Picker with System / Light / Dark options. Make it accessible (VoiceOver labels on the icon and Picker). Add a #Preview with both .light and .dark variants showing realistic content.
In the Soarias Build phase, drop this prompt into the active session after your screens are scaffolded — the agent will wire up the AppColorScheme enum, the @AppStorage key, and the root modifier in one pass, leaving you a clean diff to review before committing.
Related
FAQ
Does this work on iOS 16?
Yes — .preferredColorScheme(_:) and @AppStorage are both available from iOS 14+. The only iOS 17-specific API used in the full example is .symbolEffect(.bounce); wrap it in if #available(iOS 17, *) { } or remove it entirely and everything else compiles and runs on iOS 16 unchanged.
How do I apply a dark mode override inside a SwiftUI sheet or fullScreenCover?
Sheets are presented in a new view hierarchy layer, so they inherit the .preferredColorScheme modifier only when it is placed on the WindowGroup root — not on the view that triggers the sheet. If you see a sheet reverting to the system scheme, double-check that your modifier lives in the App struct (as shown in the full implementation), not inside a child view.
What is the UIKit equivalent?
In UIKit you set window.overrideUserInterfaceStyle to .dark, .light, or .unspecified (the equivalent of nil in SwiftUI). For individual view controllers use viewController.overrideUserInterfaceStyle. The SwiftUI .preferredColorScheme modifier maps directly to this UIKit property under the hood.
Last reviewed: 2026-05-11 by the Soarias team.