```html SwiftUI: How to Build Responsive Layout (iOS 17+, 2026)
Soarias ← SwiftUI Guides

How to Build a Responsive Layout in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: horizontalSizeClass Updated: May 11, 2026
TL;DR

Read @Environment(\.horizontalSizeClass) in your view, then return a stacked layout for .compact (iPhone) and a side-by-side layout for .regular (iPad or landscape). No third-party libraries needed.

struct ResponsiveView: View {
    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        if sizeClass == .compact {
            VStack(spacing: 16) {
                PrimaryPanel()
                SecondaryPanel()
            }
        } else {
            HStack(spacing: 24) {
                PrimaryPanel()
                SecondaryPanel()
            }
        }
    }
}

Full implementation

The pattern below uses horizontalSizeClass to switch between a single-column VStack on iPhone and a two-column HStack on iPad or resizable Split View windows. The sidebar width is pinned on regular class so content doesn't stretch uncomfortably wide, while the compact layout uses full-width cards with a ScrollView wrapper. Both previews are included so you can validate both layouts in Xcode Canvas without running a simulator.

import SwiftUI

// MARK: - Data model

struct Article: Identifiable {
    let id = UUID()
    let title: String
    let subtitle: String
    let category: String
}

extension Article {
    static let samples: [Article] = [
        .init(title: "Getting started with SwiftData", subtitle: "Persist models in minutes", category: "Data"),
        .init(title: "Mastering NavigationStack", subtitle: "Deep links & state restoration", category: "Navigation"),
        .init(title: "Animations deep dive", subtitle: "Springs, phases & keyframes", category: "Animation"),
        .init(title: "Accessibility best practices", subtitle: "VoiceOver, Dynamic Type", category: "A11y"),
    ]
}

// MARK: - Reusable card

struct ArticleCard: View {
    let article: Article

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text(article.category)
                .font(.caption.weight(.semibold))
                .foregroundStyle(.secondary)
            Text(article.title)
                .font(.headline)
            Text(article.subtitle)
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
        .padding()
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
    }
}

// MARK: - Sidebar content

struct SidebarPanel: View {
    @Binding var selected: Article?
    let articles: [Article]

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Articles")
                .font(.title2.bold())
                .padding(.horizontal)

            ForEach(articles) { article in
                Button {
                    selected = article
                } label: {
                    ArticleCard(article: article)
                        .overlay(
                            RoundedRectangle(cornerRadius: 14)
                                .stroke(selected?.id == article.id ? Color.accentColor : .clear, lineWidth: 2)
                        )
                }
                .buttonStyle(.plain)
                .padding(.horizontal)
                .accessibilityLabel("Select \(article.title)")
            }
        }
    }
}

// MARK: - Detail content

struct DetailPanel: View {
    let article: Article?

    var body: some View {
        Group {
            if let article {
                ScrollView {
                    VStack(alignment: .leading, spacing: 16) {
                        Text(article.category)
                            .font(.caption.weight(.semibold))
                            .foregroundStyle(.secondary)
                        Text(article.title)
                            .font(.largeTitle.bold())
                        Text(article.subtitle)
                            .font(.title3)
                            .foregroundStyle(.secondary)
                        Divider()
                        Text("Full article content would go here. This panel expands to fill available space on iPad and becomes a pushed navigation destination on iPhone.")
                            .foregroundStyle(.secondary)
                    }
                    .padding()
                }
            } else {
                ContentUnavailableView("Select an article", systemImage: "doc.text", description: Text("Choose an item from the list."))
            }
        }
    }
}

// MARK: - Root responsive view

struct ResponsiveArticleLayout: View {
    @Environment(\.horizontalSizeClass) private var sizeClass
    @State private var selectedArticle: Article? = Article.samples.first
    @State private var path = NavigationPath()

    private let articles = Article.samples

    var body: some View {
        if sizeClass == .compact {
            // iPhone: single-column navigation stack
            NavigationStack(path: $path) {
                ScrollView {
                    VStack(spacing: 12) {
                        SidebarPanel(selected: $selectedArticle, articles: articles)
                    }
                    .padding(.vertical)
                }
                .navigationTitle("Learn SwiftUI")
                .navigationDestination(for: Article.self) { article in
                    DetailPanel(article: article)
                        .navigationTitle(article.title)
                }
                .onChange(of: selectedArticle) { _, newValue in
                    if let article = newValue { path.append(article) }
                }
            }
        } else {
            // iPad / regular: master-detail side by side
            HStack(spacing: 0) {
                ScrollView {
                    SidebarPanel(selected: $selectedArticle, articles: articles)
                        .padding(.vertical)
                }
                .frame(width: 320)
                .background(Color(.systemGroupedBackground))

                Divider()

                DetailPanel(article: selectedArticle)
                    .frame(maxWidth: .infinity)
            }
            .ignoresSafeArea(edges: .bottom)
        }
    }
}

// MARK: - Previews

#Preview("Compact — iPhone") {
    ResponsiveArticleLayout()
        .environment(\.horizontalSizeClass, .compact)
}

#Preview("Regular — iPad") {
    ResponsiveArticleLayout()
        .environment(\.horizontalSizeClass, .regular)
}

How it works

  1. @Environment(\.horizontalSizeClass) — SwiftUI injects this automatically from the window scene. On iPhone (any orientation except Max in landscape) it is .compact; on iPad and large iPhones in landscape it is .regular. The view re-renders whenever the device rotates or a Split View column resizes.
  2. Compact branch — NavigationStack — On iPhone, tapping a card appends the Article to path, triggering the .navigationDestination to push DetailPanel. This gives you back-button navigation for free.
  3. Regular branch — fixed sidebar width — The sidebar is pinned at frame(width: 320) so it doesn't stretch. The detail panel takes the remaining space with frame(maxWidth: .infinity). A Divider() between them matches the native iPad aesthetic.
  4. Shared sub-viewsSidebarPanel and DetailPanel are layout-agnostic. Both branches reuse exactly the same components, so logic and styling stay in one place.
  5. .environment(\.horizontalSizeClass, .compact/.regular) in previews — Injecting the environment key directly lets Xcode Canvas render both layouts without needing a device or simulator, dramatically speeding up design iteration.

Variants

Adaptive grid instead of HStack

When all items are peers (no detail panel needed), a LazyVGrid with adaptive columns scales elegantly from one column on iPhone to three or four on iPad:

struct AdaptiveGridLayout: View {
    @Environment(\.horizontalSizeClass) private var sizeClass

    private var columns: [GridItem] {
        if sizeClass == .compact {
            [GridItem(.flexible())]
        } else {
            [GridItem(.adaptive(minimum: 260, maximum: 340))]
        }
    }

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 16) {
                ForEach(Article.samples) { article in
                    ArticleCard(article: article)
                }
            }
            .padding()
        }
        .navigationTitle("Adaptive Grid")
    }
}

#Preview("Compact") {
    NavigationStack { AdaptiveGridLayout() }
        .environment(\.horizontalSizeClass, .compact)
}

#Preview("Regular") {
    NavigationStack { AdaptiveGridLayout() }
        .environment(\.horizontalSizeClass, .regular)
}

Using GeometryReader for fine-grained breakpoints

horizontalSizeClass gives you two buckets; for more nuanced control (e.g., a 3-column layout that kicks in at 900 pt), wrap your root in a GeometryReader and branch on proxy.size.width. Keep the GeometryReader at the outermost layer — nesting it inside a ScrollView causes it to report zero height, a common pitfall. Prefer horizontalSizeClass when two breakpoints suffice; reach for GeometryReader only when you genuinely need pixel-level thresholds.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a responsive layout in SwiftUI for iOS 17+.
Use horizontalSizeClass to switch between a single-column
VStack on compact (iPhone) and an HStack master-detail layout
on regular (iPad / Split View).
Make it accessible (VoiceOver labels on interactive cards).
Add a #Preview with realistic sample data for both size classes.

In Soarias's Build phase, paste this prompt while your screen map is open — Claude Code will scaffold the layout files directly into your Xcode project and wire them to your existing navigation structure.

Related guides

FAQ

Does this work on iOS 16?

horizontalSizeClass itself works back to iOS 13. However, the #Preview macro requires iOS 17+ / Xcode 15+. If you target iOS 16, replace #Preview with a struct … : PreviewProvider block and use .environment(\.horizontalSizeClass, .compact) on the preview view. All other code in this guide runs unchanged on iOS 16.

How do I handle both orientation and size class changes at the same time?

horizontalSizeClass already encapsulates orientation for you on most devices — an iPhone SE in landscape is still .compact, while an iPhone 16 Pro Max in landscape flips to .regular. If you need both axes, also read @Environment(\.verticalSizeClass). Combining the two lets you detect the rare compact-width / compact-height case (iPhone landscape) and collapse navigation chrome accordingly. Avoid over-engineering this — for most apps, branching on horizontalSizeClass alone is sufficient.

What's the UIKit equivalent?

In UIKit you'd override traitCollectionDidChange(_:) on a UIViewController and read traitCollection.horizontalSizeClass (.compact / .regular), then manually add/remove constraints or swap child view controllers. SwiftUI's environment-based approach collapses all of that into a single property read and automatic view invalidation — no manual invalidation calls needed.

Last reviewed: 2026-05-11 by the Soarias team.

```