Lazy Staggered Grid in Jetpack Compose: Build Masonry Layouts

LazyVerticalStaggeredGrid is Compose’s lazy masonry-style layout: it scrolls vertically, uses multiple columns, and lets individual items have different heights. It is a good fit for image-led content whose natural proportions matter. For equal-height cards or aligned rows, use LazyVerticalGrid instead.
The official lazy lists and grids guide describes a vertical staggered grid as a vertically scrollable, multi-column container that allows individual items to have different heights. Its API reference confirms that it composes and lays out only items currently visible on screen.
Choose a staggered grid for the content, not the trend
A masonry layout reduces unused space when tiles legitimately have different heights: a photo feed, visual bookmarks, recipes with different image ratios, or a portfolio. It is not automatically better for every dashboard.
| Content shape | Best starting point |
|---|---|
| Equal tile heights with aligned rows | LazyVerticalGrid |
| Cards with intentionally different heights | LazyVerticalStaggeredGrid |
| One text-first vertical sequence | LazyColumn |
| A small, static collection | FlowRow or a regular layout |
Uneven columns are expressive, but they can make comparison and scanning harder. If strict order, predictable focus traversal, or a visual rhythm is central to the task, start with a normal grid.
Build a responsive masonry grid
StaggeredGridCells.Adaptive calculates as many lanes as fit while maintaining a minimum lane width. It is usually more resilient than choosing a phone-only fixed column count.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.height
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
data class GalleryItemUi(
val id: String,
val title: String,
val height: Dp
)
@Composable
fun GalleryGrid(
items: List<GalleryItemUi>,
onItemClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(minSize = 160.dp),
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalItemSpacing = 12.dp,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(items = items, key = { item -> item.id }) { item ->
Card(
onClick = { onItemClick(item.id) },
modifier = Modifier.fillMaxWidth().height(item.height)
) {
Text(text = item.title)
}
}
}
}This example receives heights already prepared in the UI model. In an image feed, the tile may instead use an image’s known aspect ratio. Keep that transformation in the data or presentation layer, rather than creating application state inside the grid. The state boundary and event-forwarding pattern are covered in state hoisting with Compose examples.
Adaptive versus Fixed
Use StaggeredGridCells.Fixed(2) when exactly two lanes are a deliberate requirement. Use Adaptive(minSize = ...) when the tile’s minimum usable width is the true constraint and the UI must work across window sizes.
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
verticalItemSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Items go here.
}The StaggeredGridCells reference explains that Adaptive gives every lane at least the requested minimum and distributes remaining space. Choose the minimum from real card content and touch-target needs.
Preserve identity when the feed changes
Use a stable, unique key whenever the UI model has one. The official staggered-grid API notes that a key must be unique and saveable on Android. With a key, the grid can maintain scroll position when items are inserted or removed before the visible item.
items(
items = feedItems,
key = { item -> item.id },
contentType = { item -> item.kind }
) { item ->
when (item.kind) {
FeedKind.Photo -> PhotoCard(item, onClick = { onOpen(item.id) })
FeedKind.Article -> ArticleCard(item, onClick = { onOpen(item.id) })
}
}contentType is useful when repeated item structures differ. It tells Compose which content can be reused compatibly. Never use the visible position as an ID for content that can be sorted, filtered, or refreshed.
Add a full-width state or section
Use StaggeredGridItemSpan.FullLine when an item needs every lane: a section heading, retry state, or important full-width message. It is not a replacement for a normal grid’s aligned rows.
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
LazyVerticalStaggeredGrid(columns = StaggeredGridCells.Adaptive(160.dp)) {
item(span = StaggeredGridItemSpan.FullLine) {
Text("Saved ideas")
}
items(items = ideas, key = { it.id }) { idea ->
IdeaCard(idea = idea, onClick = { onIdeaClick(idea.id) })
}
}The span API supports either SingleLane (the default) or FullLine. This differs from the GridItemSpan(maxLineSpan) pattern used by an adaptive LazyVerticalGrid.
Image tiles and accessibility
Give image-only, actionable tiles a meaningful content description, usually the item title or action. For decorative imagery whose surrounding visible label already communicates the purpose, a null description can be appropriate. Keep source dimensions or aspect ratio available before rendering; a deliberate placeholder with the same intended ratio avoids visible layout jumps as images load.
Common mistakes
Nesting it in another vertical scroller
Do not put an unconstrained LazyVerticalStaggeredGrid inside a vertically scrolling Column or LazyColumn. Same-direction scroll containers compete for measurement and gestures. Prefer one top-level lazy container, or explicitly constrain the nested grid’s height.
Expecting aligned rows
Each new tile is placed in the lane that lets the staggered layout progress; equal-height row alignment is not its goal. If a product grid must line up across the screen, use LazyVerticalGrid.
Making height differences arbitrary
Artificially varied heights often produce a busy layout without helping the content. Let legitimate media ratios or meaningful card content create the variation.
Ignoring reading order
Test TalkBack navigation, keyboard focus, and visual scanning before committing to masonry. A layout can look compact while still making a task harder if ordering feels ambiguous.
Verify it in the app
Test short and long lists, insertion before the current viewport, narrow and wide windows, large text, and image-loading placeholders. Use Android Studio’s Layout Inspector or a profiling workflow when you observe a concrete jank symptom; a lazy container is not a substitute for measuring a heavy item composable. The Compose recomposition guide can help investigate actual recomposition work.
FAQ
Is LazyVerticalStaggeredGrid lazy?
Yes. Its API reference states that it composes and lays out only currently visible items.
Can a staggered-grid item fill the whole row?
Yes. Use StaggeredGridItemSpan.FullLine for a meaningful full-width item such as a heading or retry state.
When should I use LazyVerticalGrid instead?
Use a normal grid when cards should have aligned rows and height variation is not meaningful. It offers a more predictable scanning rhythm.