LazyVerticalGrid in Jetpack Compose: Adaptive Grids That Scale

LazyVerticalGrid is Compose’s lazy, vertically scrolling layout for collections that need more than one column. Choose GridCells.Adaptive when the available width should decide the column count, use stable keys for changing data, and reserve spans for deliberate full-width content such as a section header.

The Android Developers lazy lists and grids guide describes a lazy grid as a vertically scrollable container whose items occupy multiple columns. Like LazyColumn, it composes visible content rather than eagerly laying out an entire large collection.

When LazyVerticalGrid is the right layout

Use a grid when scanning visual peers matters more than reading a single vertical sequence: a photo library, product catalogue, saved items, dashboard tiles, or an app-picker-style screen.

NeedPrefer
One ordered vertical stream of rowsLazyColumn
A horizontally scrolling stripLazyRow
Equal-width cells in a vertical, scrollable collectionLazyVerticalGrid
Tiles with intentionally uneven heightsLazyVerticalStaggeredGrid

Do not reach for a grid merely to make a list look denser. Small cells can make labels, touch targets, and keyboard focus harder to use. Start from the content’s hierarchy, then choose the layout.

A responsive grid with adaptive columns

GridCells.Adaptive gives every cell a minimum width and lets the grid calculate how many columns fit. The remaining width is shared between the columns. That makes it a solid default for a screen that can appear on phones, tablets, and resizable windows.

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
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

data class PhotoUi(
    val id: String,
    val label: String
)

@Composable
fun PhotoGrid(
    photos: List<PhotoUi>,
    onPhotoClick: (String) -> Unit,
    state: LazyGridState = rememberLazyGridState(),
    modifier: Modifier = Modifier
) {
    LazyVerticalGrid(
        columns = GridCells.Adaptive(minSize = 144.dp),
        modifier = modifier,
        state = state,
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
        horizontalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        items(
            items = photos,
            key = { photo -> photo.id }
        ) { photo ->
            PhotoTile(
                photo = photo,
                onClick = { onPhotoClick(photo.id) }
            )
        }
    }
}

@Composable
private fun PhotoTile(
    photo: PhotoUi,
    onClick: () -> Unit
) {
    Card(
        onClick = onClick,
        modifier = Modifier
            .fillMaxWidth()
            .aspectRatio(1f)
    ) {
        Text(text = photo.label)
    }
}

The item action is passed upward instead of being handled as application logic inside the grid. That keeps the UI focused on rendering state and forwarding events. If the collection comes from a ViewModel, collect it at the screen boundary and pass the resulting UI models into this composable; the state-ownership pattern is covered in state hoisting with real Compose examples.

Adaptive versus Fixed

GridCells.Fixed(2) is useful when the design truly requires two columns, such as a compact chooser with deliberately symmetric cards. It does not adapt its count to extra width.

LazyVerticalGrid(
    columns = GridCells.Fixed(2),
    contentPadding = PaddingValues(16.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp),
    horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
    items(items = shortcuts, key = { it.id }) { shortcut ->
        ShortcutTile(shortcut = shortcut)
    }
}

For content that should grow gracefully across window sizes, Adaptive(minSize = ...) is usually more resilient. Pick the minimum from the tile’s actual content: its image aspect ratio, readable text, and the minimum interactive size—not an arbitrary number copied from another screen.

Add a full-width item with a span

Grid content is described by a LazyGridScope DSL. Its item and items functions can take a span. maxLineSpan is especially useful for adaptive grids because the actual column count changes with width.

import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.items

LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 144.dp)) {
    item(span = { GridItemSpan(maxLineSpan) }) {
        Text(text = "Recently added")
    }

    items(
        items = photos,
        key = { it.id }
    ) { photo ->
        PhotoTile(photo = photo, onClick = {})
    }
}

Avoid putting every item behind span logic. A span is best for a meaningful change in hierarchy: a header, an empty state, a wide promotion, or a loading/error row. A normal image collection is easier to scan when its cells follow one predictable rhythm.

Keep identity stable when the data changes

Supply a key whenever your UI model has an obvious unique ID. Stable identity helps Compose associate an item with its state when the collection is refreshed, reordered, or filtered. The lazy-grid API also accepts contentType; use it when a single grid contains repeated, structurally different item kinds, such as a header, standard tile, and loading tile.

items(
    items = feed,
    key = { item -> item.id },
    contentType = { item -> item.kind }
) { item ->
    when (item.kind) {
        FeedKind.Photo -> PhotoTile(item.photo, onClick = {})
        FeedKind.Video -> VideoTile(item.video, onClick = {})
    }
}

The Android Developers grid API reference documents both LazyGridScope and LazyGridState, including the key, contentType, and span support. Identity is not a substitute for a correct UI model: IDs must remain unique and must not be derived from the visible position.

Control or observe grid scrolling

rememberLazyGridState() creates Compose-owned state for a grid. Hoist it when a parent composable needs to coordinate scrolling or when the screen needs to expose a scroll action. Keep the coroutine that performs a user-requested scroll close to that UI event.

import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

val gridState = rememberLazyGridState()
val scope = rememberCoroutineScope()

Button(
    onClick = {
        scope.launch { gridState.animateScrollToItem(index = 0) }
    }
) {
    Text("Back to top")
}

PhotoGrid(
    photos = state.photos,
    onPhotoClick = onPhotoClick,
    state = gridState,
    modifier = Modifier.fillMaxSize()
)

Pass gridState into the grid in your own component signature when you need the connection. Do not use visible-item information to silently change business state on every scroll; if the product needs analytics or pagination, define that event and its ownership deliberately.

Spacing, padding, and tile shape

contentPadding belongs to the scrollable content. It gives the first and last lines breathing room and also keeps grid edges clear of system bars or surrounding content. verticalArrangement controls line spacing, while horizontalArrangement controls the gap between columns.

For a visual collection, an aspectRatio often makes cells more predictable than a fixed height. For text-heavy tiles, let content dictate the height and check the result at narrow widths, large font sizes, and landscape. The Compose layout guide is a useful companion when deciding how the content inside each cell should measure and align.

Common mistakes

Nesting a vertical grid inside another vertical scroller

Avoid a LazyVerticalGrid inside a vertically scrolling Column or LazyColumn without a bounded height. Two same-direction scroll containers compete for measurement and scrolling. Prefer one top-level lazy container, or use a grid whose height is explicitly constrained when the design genuinely requires it.

Using a fixed column count everywhere

Two fixed columns may look correct on a phone but leave overly large cards on a tablet, or too little room for translated labels. Use Adaptive when the design has a minimum viable cell width rather than a strict column count.

Treating a staggered layout as a normal grid

Use LazyVerticalStaggeredGrid only when variable item height is part of the intended presentation. A normal lazy grid preserves row alignment and is usually easier for people to scan.

Forgetting empty and loading states

A grid is the loaded-content branch of a screen, not the whole screen. Give loading, empty, error, and retry states their own intentional layouts. That also avoids rendering an empty grid just because a request has not finished yet.

Accessibility and testing notes

Give every interactive tile a meaningful label through the semantics exposed by its content; image-only cards need an appropriate content description or a surrounding label that conveys the action. Test touch targets, keyboard/focus navigation, large font scaling, and at least one wider window size. If cards can reorder or change type, test a refresh with the stable key in place rather than assuming a static preview is enough.

For an overview of why lazy containers are preferable to building a large eager layout, see the existing LazyColumn guide. When you need uneven, masonry-style cells, the next step is a lazy staggered grid—not increasingly complex span rules.

FAQ

Does LazyVerticalGrid render every item at once?

No. The API reference states that it composes only visible rows of the grid. That is why it is appropriate for a collection that can grow beyond a small fixed set of tiles.

Should I use GridCells.Adaptive or GridCells.Fixed?

Use Adaptive when the minimum usable tile width matters and the screen width can vary. Use Fixed when the exact number of columns is an intentional part of the design.

Can a grid item fill the whole row?

Yes. Give the item a span of GridItemSpan(maxLineSpan). This adapts correctly even when an adaptive grid changes its column count.