How to Build a Staggered Grid in SwiftUI
SwiftUI has no built-in masonry view, but you can build one by splitting items across
multiple LazyVStack columns held in an HStack, distributing
each item to whichever column is currently shortest.
// Distribute items into N columns by shortest height
func distribute(_ items: [GridItem], columns: Int) -> [[GridItem]] {
var cols = Array(repeating: [GridItem](), count: columns)
var heights = Array(repeating: CGFloat.zero, count: columns)
for item in items {
let shortest = heights.indices.min(by: { heights[$0] < heights[$1] })!
cols[shortest].append(item)
heights[shortest] += item.height
}
return cols
}
// Render
HStack(alignment: .top, spacing: 12) {
ForEach(0..<columns, id: \.self) { col in
LazyVStack(spacing: 12) {
ForEach(distributed[col]) { item in ItemCard(item: item) }
}
}
}
Full implementation
The strategy is to pre-compute column assignments before rendering. We iterate over
every item, track each column's accumulated height, and always insert the next item
into the shortest column — just like a masonry wall. The resulting 2-D array is then
fed into an HStack of LazyVStack columns so SwiftUI can
still lazily load off-screen rows without needing a third-party library.
import SwiftUI
// MARK: - Model
struct GridItem: Identifiable {
let id = UUID()
let title: String
let color: Color
/// Intrinsic content height — drives the masonry algorithm.
let height: CGFloat
}
// MARK: - Masonry layout engine
private func masonryColumns(
_ items: [GridItem],
count: Int,
spacing: CGFloat
) -> [[GridItem]] {
var columns = Array(repeating: [GridItem](), count: count)
var colHeights = Array(repeating: CGFloat.zero, count: count)
for item in items {
// Find the column with the least total height
let target = colHeights.indices.min(by: { colHeights[$0] < colHeights[$1] })!
columns[target].append(item)
colHeights[target] += item.height + spacing
}
return columns
}
// MARK: - Card view
struct StaggeredCard: View {
let item: GridItem
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(item.color.gradient)
.frame(height: item.height)
.overlay(alignment: .bottomLeading) {
Text(item.title)
.font(.caption.weight(.semibold))
.foregroundStyle(.white)
.padding(10)
}
.accessibilityLabel(item.title)
}
}
// MARK: - Staggered grid
struct StaggeredGrid: View {
let items: [GridItem]
var columns: Int = 2
var spacing: CGFloat = 12
private var distributed: [[GridItem]] {
masonryColumns(items, count: columns, spacing: spacing)
}
var body: some View {
ScrollView(.vertical) {
HStack(alignment: .top, spacing: spacing) {
ForEach(0..<columns, id: \.self) { col in
LazyVStack(spacing: spacing) {
ForEach(distributed[col]) { item in
StaggeredCard(item: item)
}
}
}
}
.padding(spacing)
}
.scrollIndicators(.hidden)
}
}
// MARK: - Sample data
private let sampleItems: [GridItem] = [
.init(title: "Sunrise", color: .orange, height: 180),
.init(title: "Forest", color: .green, height: 120),
.init(title: "Ocean", color: .blue, height: 200),
.init(title: "Desert", color: .yellow, height: 140),
.init(title: "Mountain", color: .gray, height: 160),
.init(title: "Aurora", color: .purple, height: 220),
.init(title: "Canyon", color: .red, height: 130),
.init(title: "Meadow", color: .mint, height: 170),
.init(title: "Glacier", color: .cyan, height: 110),
.init(title: "Volcano", color: .brown, height: 190),
]
// MARK: - Preview
#Preview("2-column staggered grid") {
StaggeredGrid(items: sampleItems, columns: 2, spacing: 12)
.background(Color(.systemGroupedBackground))
}
#Preview("3-column staggered grid") {
StaggeredGrid(items: sampleItems, columns: 3, spacing: 8)
.background(Color(.systemGroupedBackground))
}
How it works
-
Masonry engine (
masonryColumns) — This pure function iterates every item once and finds the column index whosecolHeightsvalue is smallest usingmin(by:). The item goes there and its height is added to that column's running total. No SwiftUI geometry readers required — it's all pre-computed before render. -
HStack(alignment: .top)— Aligns the tops of all column stacks so columns start flush at the top of the scroll view regardless of varying content heights. Without.topalignment, SwiftUI defaults to.centerand the grid looks misaligned. -
LazyVStackper column — Each column uses its ownLazyVStack, so SwiftUI only renders cells that are near the visible viewport. A plainVStackwould eagerly instantiate every card even off-screen, hurting scroll performance on large data sets. -
frame(height: item.height)on the card — The card height is explicit, which is what makes the stagger visible. If heights were.infinityor unspecified, all cards would be equal height and the layout would look like a regular grid. Pass heights from your data model (e.g., aspect-ratio from an image's metadata). -
accessibilityLabelon each card — VoiceOver reads the label in DOM order (column-by-column), not visual reading order. If reading order matters for your content, consider adding.accessibilitySortPriorityor restructuring the data order passed to each column.
Variants
Dynamic column count from geometry
Adapt the column count to the available width so the grid works on every device from iPhone SE to iPad Pro without any special-casing.
struct AdaptiveStaggeredGrid: View {
let items: [GridItem]
var minColumnWidth: CGFloat = 160
var spacing: CGFloat = 12
var body: some View {
GeometryReader { proxy in
let cols = max(1, Int(proxy.size.width / (minColumnWidth + spacing)))
StaggeredGrid(items: items, columns: cols, spacing: spacing)
}
}
}
#Preview {
AdaptiveStaggeredGrid(items: sampleItems, minColumnWidth: 140)
}
Loading remote images with dynamic heights
When heights come from a network API (e.g. Unsplash-style JSON with
width/height fields), compute each item's display height as
(columnWidth / imageWidth) * imageHeight before constructing the
GridItem array. Pass the computed height into the model at the call site so
the masonry engine still receives fixed values — never rely on a geometry reader inside
the lazy stack to drive layout or you'll hit render loops.
Common pitfalls
-
iOS 16 and earlier:
LazyVStackexists since iOS 14, but the#Previewmacro requires Xcode 15+ / iOS 17 deployment target. If you need iOS 16 support, swap#PreviewforPreviewProvider— the grid logic itself is backward-compatible. -
Recomputing layout on every state change:
masonryColumnsis called inside avar distributedcomputed property, meaning it re-runs whenever the view re-renders. For large item counts (>500), cache the result with@Stateor compute it inside ataskmodifier to keep the main thread free. -
VoiceOver reading order: Because each column is a separate
LazyVStack, VoiceOver traverses column 0 entirely before column 1. If your items have a natural chronological or ranked order, this can confuse users. Wrap the whole grid in.accessibilityElement(children: .contain)and provide a custom rotor if sequential reading order is critical. -
Missing
id:onForEach(0..<columns): Withoutid: \.self, SwiftUI may misidentify columns during dynamic column-count changes and animate items to wrong positions. Always provide an explicitid.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a staggered grid (masonry layout) in SwiftUI for iOS 17+. Use LazyVStack and HStack. Distribute items to the shortest column using a pure function. Support a configurable column count and spacing. Make it accessible (VoiceOver labels on each card). Add a #Preview with realistic sample data including varied heights.
In the Soarias Build phase, paste this prompt directly into the implementation panel — Soarias will scaffold the masonry engine and card view, then wire them into your existing SwiftData model so real content heights flow straight from your persistence layer.
Related
FAQ
Does this work on iOS 16?
Yes — LazyVStack, HStack, and ScrollView are all
available from iOS 14. The only iOS 17-specific feature used in the full example is the
#Preview macro. Replace it with a struct ContentPreviews: PreviewProvider
block and the layout code runs unchanged on iOS 16.
How do I get item heights from remote images?
Fetch your image metadata (width + height in pixels) from your API alongside the image URL.
Before building the [GridItem] array, compute a display height for each item:
displayHeight = (columnWidth / imageNativeWidth) * imageNativeHeight.
Use a GeometryReader once at the parent level to get columnWidth
(total width divided by column count minus spacing), then pass the pre-calculated heights
into the model. Avoid reading geometry inside LazyVStack cells — it triggers
layout loops.
What is the UIKit equivalent?
In UIKit the equivalent is UICollectionView with a custom
UICollectionViewLayout subclass — specifically the classic "Pinterest layout"
that overrides prepare() to compute frame attributes per item.
Apple's UICollectionViewCompositionalLayout does not natively support masonry
either; you still need a custom layout or a library like CHTCollectionViewWaterfallLayout.
The SwiftUI approach here is simpler for most use cases and integrates naturally with
@Observable data models.
Last reviewed: 2026-05-11 by the Soarias team.