How to style hierarchy in SwiftUI

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.

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation