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.
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.
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.
// 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.
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.
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.
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.
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
Define an adaptive column
Use a single .adaptive GridItem with a minimum cell width.
let columns = [GridItem(.adaptive(minimum: 120), spacing: 12)] - 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
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
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?
Why does GridItem(.adaptive(minimum:)) show a different number of columns on iPhone versus iPad in SwiftUI?
How do I make SwiftUI LazyVGrid section headers stick to the top while scrolling?
Why do my SwiftUI photo grid images stutter and reload as I scroll a LazyVGrid?
What controls the vertical gap versus horizontal gap in a SwiftUI LazyVGrid?
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 — $99One-time purchase · Lifetime updates · 14-day refund