SwiftUI Tutorial

SwiftUI Grid, LazyVGrid, and LazyHGrid: The Complete Layout Guide

Learn when to reach for LazyVGrid versus the static Grid, how GridItem .flexible, .adaptive, and .fixed actually size columns, how to pin section headers, and how to build a smooth photo grid. Compilable Swift 6 code throughout.

Last updated: 2026-07-17 10 min read By Ahmed Gagan, iOS Engineer
Quick Answer

Use LazyVGrid(columns:) with an array of GridItem to lay out scrolling content in columns; .adaptive fits as many items as will fit, .flexible splits space evenly, and .fixed pins an exact width. For a small, non-scrolling table where rows must align across columns, use the iOS 16 Grid with GridRow. Wrap lazy grids in a ScrollView and use pinnedViews: [.sectionHeaders] for sticky headers.

Scrolling grids
LazyVGrid / LazyHGrid inside a ScrollView
Static alignment
Grid + GridRow (iOS 16+) for cell-aligned tables
Column sizing
GridItem .flexible, .adaptive(minimum:), or .fixed
Swift Kit support
The Swift Kit includes a reusable adaptive photo-grid component with AsyncImage caching, ready to drop into any gallery screen.

LazyVGrid vs static Grid: pick the right tool

SwiftUI has two grid systems that solve different problems. LazyVGrid and LazyHGrid are lazy, scrolling containers: they create cells on demand as you scroll, which is exactly what you want for a gallery of hundreds of photos. The static Grid, introduced in iOS 16, is not lazy — it renders every cell up front and, critically, aligns cells across rows into true columns like an HTML table. Use Grid for a small settings table or a scoreboard where column widths must match across rows; use LazyVGrid for large collections where alignment across rows doesn't matter and performance does. Mixing them up is the most common grid mistake.

  • LazyVGrid/LazyHGrid: lazy, scrolling, for large collections
  • Grid/GridRow: eager, cell-aligned, for small structured tables
  • Lazy grids need an enclosing ScrollView; Grid does not
  • Grid aligns columns across rows; LazyVGrid does not

GridItem: flexible, adaptive, and fixed

A LazyVGrid's layout is entirely defined by the columns array of GridItem values. .flexible(minimum:maximum:) gives each named column an equal share of the available width — three .flexible items make three even columns. .adaptive(minimum:) is the powerful one: you specify a minimum cell width and SwiftUI fits as many columns as will fit, reflowing automatically as the screen widens (perfect for supporting iPhone and iPad from one definition). .fixed(_:) locks a column to an exact point width. You can mix them — a .fixed sidebar column beside a .flexible content column — and each GridItem also carries its own spacing and alignment.

Three ways to define columns
// Even three-column split
let even = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]

// As many columns as fit, each at least 100pt wide
let adaptive = [GridItem(.adaptive(minimum: 100), spacing: 8)]

// A fixed 80pt column next to a flexible one
let mixed = [GridItem(.fixed(80)), GridItem(.flexible())]

struct ColorGrid: View {
    let columns = [GridItem(.adaptive(minimum: 100), spacing: 8)]
    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 8) {
                ForEach(0..<40, id: \.self) { i in
                    RoundedRectangle(cornerRadius: 8)
                        .fill(Color(hue: Double(i) / 40, saturation: 0.7, brightness: 0.9))
                        .frame(height: 100)
                }
            }
            .padding()
        }
    }
}

Pinned section headers and spacing

LazyVGrid supports sections with headers and footers, and it can pin those headers so they stick to the top while their section scrolls — a native alternative to building sticky headers by hand. Pass pinnedViews: [.sectionHeaders] to the grid and wrap each group in a Section with a header view. Be deliberate about spacing: the grid's own spacing argument controls the gap between rows, while each GridItem's spacing controls the gap between columns. These are independent, and forgetting the row spacing is why grids sometimes look cramped vertically even when columns breathe. Give header views a solid background, otherwise pinned headers show cells scrolling behind them.

Sectioned grid with pinned headers
struct SectionedGrid: View {
    let columns = [GridItem(.adaptive(minimum: 90), spacing: 10)]
    let groups = ["Recent", "Favorites", "Archived"]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 10, pinnedViews: [.sectionHeaders]) {
                ForEach(groups, id: \.self) { group in
                    Section {
                        ForEach(0..<12, id: \.self) { _ in
                            RoundedRectangle(cornerRadius: 8)
                                .fill(.blue.opacity(0.3))
                                .frame(height: 90)
                        }
                    } header: {
                        Text(group)
                            .font(.headline)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .padding(.vertical, 6)
                            .background(.regularMaterial)
                    }
                }
            }
            .padding(.horizontal)
        }
    }
}

The static Grid with GridRow

For content that must align into a real table — labels beside values, a comparison matrix — the iOS 16 Grid is the right tool. You declare rows with GridRow, and cells in the same column position line up across every row automatically, sizing each column to its widest cell. An empty cell can be produced with Color.clear, and a view can span multiple columns with .gridCellColumns(_:). Because Grid renders eagerly, keep it to modest sizes; a hundred rows belongs in a lazy grid or a List. Grid also honors alignment per column via .gridColumnAlignment, letting you right-align numbers while left-aligning labels.

A static aligned comparison table
struct PlanComparison: View {
    var body: some View {
        Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
            GridRow {
                Text("Feature").bold()
                Text("Free").bold()
                Text("Pro").bold()
            }
            Divider().gridCellColumns(3)
            GridRow {
                Text("Cloud Sync")
                Image(systemName: "xmark").foregroundStyle(.red)
                Image(systemName: "checkmark").foregroundStyle(.green)
            }
            GridRow {
                Text("AI Suggestions")
                Image(systemName: "xmark").foregroundStyle(.red)
                Image(systemName: "checkmark").foregroundStyle(.green)
            }
        }
        .padding()
    }
}

Building a performant photo grid

A photo grid is the canonical LazyVGrid use case: an .adaptive column set, square cells via a 1:1 aspect ratio, and AsyncImage to load thumbnails. Two performance details matter. First, give each cell a fixed shape and clip the image so a slow-loading photo doesn't push neighbors around — use .aspectRatio(contentMode: .fill) with .clipped(). Second, because AsyncImage doesn't cache aggressively, back it with a real image cache or a library for large galleries; re-fetching on every scroll is the usual cause of jank. Set an explicit id on your ForEach data so SwiftUI can reuse cells correctly as content changes.

An adaptive photo grid with AsyncImage
struct PhotoGrid: View {
    let urls: [URL]
    let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 2) {
                ForEach(urls, id: \.self) { url in
                    Color.clear
                        .aspectRatio(1, contentMode: .fit)
                        .overlay {
                            AsyncImage(url: url) { image in
                                image.resizable().scaledToFill()
                            } placeholder: {
                                Color.gray.opacity(0.2)
                            }
                        }
                        .clipped()
                }
            }
        }
    }
}

A photo grid that just works

The Swift Kit ships a reusable adaptive photo-grid component with cached AsyncImage loading and stable cell reuse, so galleries scroll smoothly without you writing a caching layer.

Get The Swift Kit — $99

How to build a responsive adaptive grid

One GridItem definition can serve iPhone and iPad by letting the column count flex with available width.

  1. 1

    Define an adaptive column

    Use a single .adaptive GridItem with a minimum cell width.

    let columns = [GridItem(.adaptive(minimum: 120), spacing: 12)]
  2. 2

    Wrap in a ScrollView

    Lazy grids don't scroll themselves; embed the LazyVGrid in a ScrollView.

    ScrollView { LazyVGrid(columns: columns, spacing: 12) { /* cells */ } }
  3. 3

    Give cells a stable shape

    Fix each cell's aspect ratio so loading content doesn't shift the layout.

    Color.clear.aspectRatio(1, contentMode: .fit).overlay { /* content */ }
  4. 4

    Set an explicit id

    Provide a stable id on ForEach so cells reuse correctly as data changes.

    ForEach(items, id: \.id) { item in Cell(item) }

Frequently Asked Questions

When should I use SwiftUI's static Grid instead of LazyVGrid for a screen of items?
Use the static Grid with GridRow when cells must align into true columns across rows — a comparison table, a scoreboard, label/value pairs. Use LazyVGrid for large scrolling collections where cross-row alignment doesn't matter and lazy loading keeps scrolling smooth. Grid renders eagerly, so keep it small.
Why does GridItem(.adaptive(minimum:)) show a different number of columns on iPhone versus iPad in SwiftUI?
That's the intended behavior. .adaptive fits as many columns of at least the minimum width as the available space allows, so a wider iPad screen naturally produces more columns from the exact same definition. It's the simplest way to make one grid responsive across devices.
How do I make SwiftUI LazyVGrid section headers stick to the top while scrolling?
Pass pinnedViews: [.sectionHeaders] to the LazyVGrid and wrap each group in a Section with a header view. Give the header a solid or material background, otherwise you'll see cells scrolling behind the pinned header.
Why do my SwiftUI photo grid images stutter and reload as I scroll a LazyVGrid?
AsyncImage does minimal caching, so it refetches thumbnails each time a cell reappears. Back your cells with a real image cache or an image-loading library, give each cell a fixed aspect ratio, and set a stable ForEach id so SwiftUI reuses cells instead of rebuilding them.
What controls the vertical gap versus horizontal gap in a SwiftUI LazyVGrid?
The LazyVGrid's own spacing argument sets the gap between rows, while each GridItem's spacing sets the gap between columns. They're independent — cramped vertical spacing usually means you set the GridItem spacing but forgot the grid's row spacing.

Keep exploring

Skip the layout plumbing

The Swift Kit is a $99 one-time SwiftUI boilerplate with a design system, reusable grid and gallery components, auth, and paywalls — so you build features, not scaffolding.

Get The Swift Kit — $99

One-time purchase · Lifetime updates · 14-day refund