```html SwiftUI: How to Build a Staggered Grid (iOS 17+, 2026)

How to Build a Staggered Grid in SwiftUI

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

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

  1. Masonry engine (masonryColumns) — This pure function iterates every item once and finds the column index whose colHeights value is smallest using min(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.
  2. 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 .top alignment, SwiftUI defaults to .center and the grid looks misaligned.
  3. LazyVStack per column — Each column uses its own LazyVStack, so SwiftUI only renders cells that are near the visible viewport. A plain VStack would eagerly instantiate every card even off-screen, hurting scroll performance on large data sets.
  4. frame(height: item.height) on the card — The card height is explicit, which is what makes the stagger visible. If heights were .infinity or 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).
  5. accessibilityLabel on 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 .accessibilitySortPriority or 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

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.

```