How to Build a Responsive Layout in SwiftUI
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
-
@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. -
Compact branch —
NavigationStack— On iPhone, tapping a card appends theArticletopath, triggering the.navigationDestinationto pushDetailPanel. This gives you back-button navigation for free. -
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 withframe(maxWidth: .infinity). ADivider()between them matches the native iPad aesthetic. -
Shared sub-views —
SidebarPanelandDetailPanelare layout-agnostic. Both branches reuse exactly the same components, so logic and styling stay in one place. -
.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
-
⚠️ iOS version gotcha:
horizontalSizeClassis available since iOS 13, but the#Previewmacro and injecting environment values into it only works in Xcode 15+ / iOS 17+ targets. If your deployment target is below iOS 17, you must usePreviewProviderwith.environmentmodifiers instead. -
⚠️ Size class is
Optional<UserInterfaceSizeClass>: The environment value is optional, not a plain enum. Always compare with== .compactrather than pattern-matching — if the value isnil(e.g., in a watchOS or macOS context), theelsebranch safely handles it as regular. -
⚠️ Don't animate the layout switch naively: Wrapping the
if/elsebranch inwithAnimationwhen the size class changes can cause jarring cross-fades. Use.animation(.easeInOut, value: sizeClass)on the container, or suppress animation for rotation-triggered layout flips entirely. -
⚠️ Accessibility — don't hide content: If your compact layout collapses panels that are visible in regular, ensure the hidden content is still reachable via VoiceOver or accessible through navigation. Never use
.hidden()alone to collapse items — remove them from the view tree with anifstatement so assistive technologies don't trip over invisible elements.
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.