3.5 — Adaptive design: Dark Mode & Dynamic Type
Opening scenario
Your CEO uses Dark Mode and the largest accessibility text size. You show her the new feature in TestFlight. The button text overflows the button. The white background blinds her. The “subtle gray” caption is invisible. She closes the build, says “looks broken,” and moves on.
Two settings — Settings > Display & Brightness > Dark and Settings > Accessibility > Display & Text Size. Both ship by default on iOS. Both are toggles your designer probably did not check. Your job is to make the app look excellent in every combination — light/dark × XS through XXXL text — before it ships.
| Adaptation | What changes | API surface |
|---|---|---|
| Color appearance | Light / Dark / Increased Contrast | colorScheme, Asset Catalog appearances |
| Dynamic Type | xSmall → AX5 (12 sizes) | font(.body) + Dynamic Type-aware fonts |
| Reduced motion | Disable spring/parallax | \.accessibilityReduceMotion |
| Reduced transparency | Replace materials with solid | \.accessibilityReduceTransparency |
| Bold text | All text becomes bold | Automatic for system fonts |
Concept → Why → How → Code
Dark Mode — the basics
Detect the current appearance:
struct MyView: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
Image(colorScheme == .dark ? "logo-dark" : "logo-light")
}
}
But you almost never need to branch on colorScheme directly — use Asset Catalog colors and the system semantic colors instead:
Color(.systemBackground) // Adapts automatically
Color("Brand") // Asset Catalog: Any + Dark appearances → adapts automatically
Branch on colorScheme only when:
- You need a different image (not just tint)
- You need a different layout (rare)
- You’re computing a derived color that the Asset Catalog can’t express
Dark Mode pitfalls
// ❌ Wrong — hardcoded white on white
ZStack {
Color.white // breaks in dark mode
Text("Hello")
.foregroundStyle(.black) // breaks in dark mode
}
// ✅ Right — semantic
ZStack {
Color(.systemBackground)
Text("Hello")
.foregroundStyle(.primary)
}
Shadow opacity needs adjustment for dark mode (less effective on dark bg):
.shadow(
color: Color.black.opacity(colorScheme == .dark ? 0.4 : 0.1),
radius: 4
)
Or use Asset Catalog “Shadow Color” with appearance variants.
Forcing appearance per scene (rare, but useful)
// Force dark mode for a single view
MyView()
.preferredColorScheme(.dark)
// At the scene level (App entry)
WindowGroup {
ContentView()
.preferredColorScheme(.dark) // ignores user setting
}
Use sparingly — fighting the user’s preference annoys them. Acceptable cases: a dedicated dark-themed app (Halide camera), a video player, an immersive reader mode where the user opts in.
Dynamic Type — the scale
Apple’s Dynamic Type slider has 7 standard sizes (xS, S, M, L, default, xL, xxL, xxxL) and 5 accessibility sizes (AX1–AX5). At AX5, your .body text grows from 17pt to ~53pt. Headlines grow proportionally.
The rule: never use .font(.system(size: 17)) — it doesn’t scale. Use:
Text("Hello").font(.body) // scales
Text("Hello").font(.headline) // scales
Text("Hello").font(.title) // scales
// For custom fonts: use relativeTo:
Text("Hello").font(.custom("Inter", size: 17, relativeTo: .body))
Limit growth (when needed)
Buttons in nav bars can’t grow to AX5 size — they’d overflow. Cap them:
Text("Done")
.font(.body)
.dynamicTypeSize(...DynamicTypeSize.accessibility2)
This means “scale up to AX2, then stop.” Use this for fixed-height UI (tab bars, nav bars, toolbar buttons). Never use it for content text — capping body content breaks accessibility.
Layout strategies for large text
Three patterns to handle large text gracefully:
1. Vertical fallback at large sizes
struct AdaptiveLabel: View {
let icon: String
let title: String
@Environment(\.dynamicTypeSize) var typeSize
var body: some View {
if typeSize.isAccessibilitySize {
VStack {
Image(systemName: icon)
Text(title)
}
} else {
HStack {
Image(systemName: icon)
Text(title)
}
}
}
}
SwiftUI ships this built into Label for free — but custom layouts may need this logic.
2. ViewThatFits
ViewThatFits {
HStack { Image("icon"); Text("Long label that might overflow") }
VStack { Image("icon"); Text("Long label that might overflow") }
Image("icon") // last resort, drop the label
}
SwiftUI picks the first child that fits. Free responsive design with zero conditional code.
3. Wrap, don’t truncate
Text("A long string that should wrap rather than truncate")
.lineLimit(nil) // allow unlimited lines
.fixedSize(horizontal: false, vertical: true)
The fixedSize trick is the classic SwiftUI escape from “my text is being cut off because the parent doesn’t give it enough height.”
Testing — Environment Overrides in Xcode
Run your app in Simulator. In Xcode’s debug bar:
- Environment Overrides button (looks like a slider)
- Toggle Interface Style: Light / Dark
- Toggle Text Size: drag the slider through every value
- Toggle Increase Contrast, Bold Text, Reduce Motion
You can flip these while the app is running. Cycle through them on every PR. If anything breaks, fix before merge.
Other accessibility adaptations
// Respect reduced motion — disable parallax, swap spring for linear
@Environment(\.accessibilityReduceMotion) var reduceMotion
withAnimation(reduceMotion ? .linear(duration: 0.1) : .spring()) {
offset = newValue
}
// Respect reduced transparency — use solid instead of material
@Environment(\.accessibilityReduceTransparency) var reduceTransparency
.background(reduceTransparency ? Color(.systemBackground) : nil)
.background(reduceTransparency ? nil : .ultraThinMaterial)
Color contrast — increased contrast mode
iOS has an Increased Contrast setting (Settings → Accessibility → Display & Text Size → Increase Contrast). When on, iOS darkens text, brightens backgrounds, and amplifies separators. Asset Catalog supports a third appearance variant: Any, Dark, High Contrast:
- Open your Color in Asset Catalog
- Attributes → Appearances → “Any, Dark, High Contrast”
- You now have four color slots: Light, Dark, Light-HiContrast, Dark-HiContrast
Provide values for all four. Test by toggling Increase Contrast.
In the wild
- Apple Notes is the textbook example: every text scales, every color adapts, the toolbar gracefully wraps at AX5. Try it.
- Twitter (old) was notorious for breaking at AX3+ because the timeline cells had fixed heights. Lesson: don’t pin heights in scrollable content.
- The Wirecutter app ships with adaptive layouts that switch from 3-column to 1-column at AX2 — read NYT engineering blog for the case study.
- Apple Maps has a custom “Increase Contrast” map style that activates with the system setting — high-end adaptive design.
Common misconceptions
- “Dark mode = invert colors.” No. Dark mode usually uses a desaturated near-black, with brand colors less saturated than their light-mode counterparts.
- “Most users don’t change text size.” Wrong. Apple has cited 30%+ of users adjust text size. Older demographics: closer to 50%.
- “Accessibility text sizes are for blind users.” No — VoiceOver is for blind users. Dynamic Type is for low-vision and aging-eyes users, a much larger group.
- “I’ll add Dark Mode in v2.” You won’t. Dark Mode adoption requires touching every screen. Build it from day one.
- “
@Environment(\.colorScheme)is what I use to handle dark mode.” Only as a last resort. Asset Catalog colors and semantic system colors are the right tool 95% of the time.
Seasoned engineer’s take
The first PR test I add to any new view: a screenshot test that captures the view at light/dark × default/AX3. If something visually breaks in any combination, the test fails. (Use snapshot-testing — it’s the standard.)
Reality check: at AX5, your design will look ridiculous if it was designed for default size. That’s not your bug — that’s a designer who didn’t consider accessibility. The fix is ViewThatFits + .dynamicTypeSize(...) caps on chrome + unlimited line wrap on content. Don’t ship a design that fits only default size.
The best teams have a Friday ritual: open the app at AX5 + Dark + Increased Contrast + Reduced Motion. Use it for 15 minutes. Note what breaks. File issues. Fix on Monday.
TIP: Add a debug menu in your app to toggle these settings without leaving the app. Saves hours during development.
WARNING: Never set
.font(.system(size: ...))in shipped code. If you must use a fixed size (rare — measure twice), document why. Default rule: every.font(...)reads as a semantic style.
Interview corner
Junior-level: “How do you support Dark Mode in SwiftUI?”
Asset Catalog colors with Any/Dark appearance variants, and semantic system colors (Color(.systemBackground), .primary/.secondary foreground styles). Avoid hardcoded colors. Avoid branching on colorScheme unless asset variants can’t express what you need.
Mid-level: “What’s Dynamic Type? Walk me through how you’d support it.”
iOS user-controlled text scaling, 12 sizes including 5 accessibility sizes. Support it by always using semantic font styles (.body, .headline, etc.) and relativeTo: for custom fonts. Test with Environment Overrides at AX3+. Use ViewThatFits for layouts that need to reflow. Cap chrome text with .dynamicTypeSize(...).
Senior-level: “How would you build a CI check that prevents Dark Mode and Dynamic Type regressions?”
Snapshot tests using swift-snapshot-testing. For each major screen, capture screenshots at (light, dark) × (default, AX3) — 4 snapshots each. Run on every PR. Diffs fail the build. Reviewer must approve visual changes explicitly. Optional: pixel-perfect diff vs Figma export.
Red flag in candidates: Saying they “don’t really test dark mode, the designer signs off.” Tells you they ship broken adaptive UX.
Lab preview
You’ll fix adaptive design bugs in Lab 3.2 — HIG & Accessibility Audit, and verify your palette across modes in Lab 3.3.