Issue #1018
Visual hierarchy is what separates polished apps from cluttered ones. When elements compete for attention equally, users struggle to know where to focus. SwiftUI provides powerful tools for creating hierarchy—through layered backgrounds, foreground styles, and multi-toned symbols—but they’re easy to overlook if you don’t know where to find them.
In this article, we’ll explore three complementary techniques: hierarchical background styles for creating depth in containers, foreground styles for text and content hierarchy, and symbol rendering modes for adding dimension to icons.
The Challenge with Layered Containers
Imagine building a settings screen with nested groups. Each level needs to stand out from its parent, but hardcoding colors creates maintenance headaches and breaks the moment someone enables dark mode:
// Fragile approach—breaks in different color schemes
VStack {
content
.background(Color.gray.opacity(0.1))
}
.background(Color.white)
SwiftUI offers a cleaner solution: hierarchical shape styles that adapt automatically to the current appearance.
Accessing Background Hierarchy Directly
ShapeStyle provides instance properties that let you access multiple visual levels: secondary, tertiary, quaternary, and quinary. These work relative to the style you call them on.
Here’s what clean hierarchical backgrounds look like:
struct AccountCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Profile")
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
InfoRow(label: "Name", value: "Sarah Chen")
InfoRow(label: "Plan", value: "Premium")
}
.padding()
.background(.background.secondary, in: .rect(cornerRadius: 10))
}
.padding()
.background(.background, in: .rect(cornerRadius: 14))
}
}
The expression .background.secondary tells SwiftUI to use the secondary level of the current background style. Light mode, dark mode, increased contrast—it all adapts automatically.
The Five Levels of ShapeStyle
ShapeStyle provides a complete hierarchy:
| Level | Property | Visual Weight |
|---|---|---|
| 1 | .primary |
Base level |
| 2 | .secondary |
First nested level |
| 3 | .tertiary |
Deeper nesting |
| 4 | .quaternary |
Subtle distinction |
| 5 | .quinary |
Minimal contrast |
In practice, you’ll rarely need more than three levels. Going deeper risks creating visual noise rather than clarity.
Applying Hierarchy to Tints and Colors
These hierarchical properties work on any ShapeStyle, not just backgrounds. Want layered tints for a branded component? Access them on .tint:
struct FeatureCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Label("New Feature", systemImage: "star.fill")
.font(.headline)
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.tint, in: .capsule)
Text("Discover what's possible with our latest update.")
.font(.subheadline)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.tint.tertiary, in: .rect(cornerRadius: 8))
}
.padding()
.background(.tint.quaternary, in: .rect(cornerRadius: 14))
.tint(.purple)
}
}
Change the tint color, and every level updates proportionally. The same technique works with any Color:
let brandOrange = Color.orange
VStack {
content
.background(brandOrange.secondary)
}
.background(brandOrange.quaternary)
Foreground Style Hierarchy
The foregroundStyle modifier does more than set colors—it establishes a hierarchy that child views can reference. When you apply .secondary or .tertiary to nested elements, they modify the parent’s foreground style rather than imposing their own.
struct ArticlePreview: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Breaking News")
.font(.headline)
// Uses primary foreground style
Text("Major developments in technology sector")
.font(.subheadline)
.foregroundStyle(.secondary)
// Uses reduced opacity of parent's style
Text("5 minutes ago")
.font(.caption)
.foregroundStyle(.tertiary)
// Even more subtle
}
.foregroundStyle(.blue)
// All text inherits from this blue style
}
}
The headline appears in full blue, the subheadline in a lighter blue, and the timestamp in an even subtler shade. Remove the parent’s .foregroundStyle(.blue), and everything gracefully falls back to the system’s default foreground colors.
Targeting Specific Hierarchy Levels
The foregroundStyle modifier accepts multiple parameters to target specific hierarchy levels independently:
// Apply blue only to primary level, leave secondary as system default
.foregroundStyle(.blue, .secondary)
// Apply blue only to secondary level
.foregroundStyle(.primary, .blue.secondary)
// Different colors for primary and secondary
.foregroundStyle(.indigo, .orange)
This is particularly useful when you want to tint certain content while preserving system defaults for supporting text:
struct PricingRow: View {
var body: some View {
HStack {
VStack(alignment: .leading) {
Text("Pro Plan")
.font(.headline)
Text("Unlimited access")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text("$9.99/mo")
.font(.title2.weight(.semibold))
}
.foregroundStyle(.green, .secondary)
// Green for primary text, system gray for secondary
}
}
Gradients as Foreground Styles
Since foregroundStyle accepts any ShapeStyle, you can apply gradients directly:
Text("Premium")
.font(.largeTitle.weight(.black))
.foregroundStyle(
LinearGradient(
colors: [.purple, .pink, .orange],
startPoint: .leading,
endPoint: .trailing
)
)
Child views using .secondary or .tertiary will receive lighter versions of this gradient, maintaining visual cohesion.
Symbol Rendering Modes
SF Symbols aren’t just flat icons—many contain multiple layers that can be rendered with different visual weights. Understanding the rendering modes unlocks more expressive iconography.
SwiftUI offers four ways to render symbol layers:
Monochrome renders every layer in the same color. It’s the default, and it’s perfect for simple, uniform icons.
Hierarchical renders layers at different opacities, creating depth within a single color:
Image(systemName: "square.stack.3d.up.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)
.font(.system(size: 48))
The front layer appears at full opacity, while background layers fade progressively.
Palette lets you assign different colors to different layers:
Image(systemName: "person.crop.circle.badge.checkmark")
.symbolRenderingMode(.palette)
.foregroundStyle(.blue, .green)
.font(.system(size: 48))
The first style applies to the primary layer, and the second to the badge. If the symbol has three layers, you can provide three styles.
Multicolor uses Apple’s predefined colors baked into the symbol definition:
Image(systemName: "cloud.sun.rain.fill")
.symbolRenderingMode(.multicolor)
.font(.system(size: 48))
Note: When applying custom foreground styles to symbols that render hierarchically by default, add
.symbolRenderingMode(.monochrome)to ensure your styles apply correctly to all levels.
Triggering Palette Mode Automatically
Providing multiple colors to foregroundStyle() automatically activates palette rendering—no need for the explicit modifier:
Image(systemName: "battery.100.bolt")
.foregroundStyle(.green, .yellow)
.font(.system(size: 48))
This renders the battery in green and the lightning bolt in yellow.
Variable Symbols for Dynamic State
Some SF Symbols support variable values between 0.0 and 1.0. This is useful for indicators like volume, signal strength, or battery level:
struct VolumeIndicator: View {
let level: Double
var body: some View {
Image(systemName: "speaker.wave.3.fill", variableValue: level)
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.primary)
.font(.title)
}
}
At variableValue: 0.3, only the first wave renders fully. At 1.0, all waves appear.
Symbol Variants for Context
The symbolVariant() modifier adapts symbols to their context:
Image(systemName: "heart")
.symbolVariant(.fill) // Solid heart
Image(systemName: "wifi")
.symbolVariant(.slash) // Indicates unavailable
Image(systemName: "plus")
.symbolVariant(.circle.fill) // Enclosed in filled circle
Use outline variants in toolbars where symbols sit against plain backgrounds, and filled variants in tab bars where selection state needs emphasis.
Wrapping Up
SwiftUI’s hierarchy tools work across three dimensions:
For containers, access .secondary, .tertiary, and .quaternary on any ShapeStyle to create layered backgrounds that adapt automatically.
For content, use foregroundStyle to establish a parent style that child views can reference with .secondary and .tertiary modifiers.
For symbols, choose the right rendering mode—hierarchical for single-color depth, palette for multi-color precision—and use variable values for dynamic state representation.
When you combine these techniques, your interfaces communicate importance naturally. Users don’t need to think about where to look—the visual weight guides them there.
Start the conversation