LazyRow in Jetpack Compose: Horizontal Lists Done Right

Quick answer: Use
LazyRowfor a horizontally scrolling collection whose size is large or unknown. Give items stable keys when their order can change, usecontentPaddingfor edge space andArrangement.spacedBy()for gaps, and keep collection data and selection state outside the row. ALazyRowcomposes and lays out only visible items.
LazyRow is the horizontal counterpart to LazyColumn. It is ideal for media shelves, filter chips, product cards, recent items, and any row where composing every element at once would be wasteful. For a tiny, static set of non-scrolling children, a normal Row is simpler.
When LazyRow is the right choice
| Situation | Use |
|---|---|
| Large or server-driven horizontal collection | LazyRow |
| Few static children that do not scroll | Row |
| A page-at-a-time carousel | HorizontalPager |
| A two-dimensional collection | LazyVerticalGrid or LazyHorizontalGrid |
Android’s lazy-lists guide explains that LazyRow lays out items horizontally and composes only what is visible in the viewport. The LazyRow reference describes its LazyListScope DSL for emitting single items and collections.
Start with a state-driven row
The screen state holder supplies the cards and receives interactions. The row only renders those values and forwards a selected ID.
@Composable
fun RecommendedShelf(
articles: List<ArticleUi>,
onArticleClick: (String) -> Unit,
) {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items = articles,
key = { article -> article.id },
) { article ->
ArticleCard(
article = article,
onClick = { onArticleClick(article.id) },
modifier = Modifier.width(240.dp),
)
}
}
}contentPadding creates the leading and trailing inset inside the scrollable content, while horizontalArrangement creates the gap between items. This keeps the first and last card aligned with the rest of the screen without adding fake spacer items. The current API reference explicitly recommends horizontalArrangement for item spacing.
The row should not own selected IDs, remote loading, or navigation. Lift those values into screen state and handle the callback in the ViewModel or navigation layer. This follows the state-hoisting pattern.
Use stable keys when data can move
Without a key, a lazy item is identified by its position. If items are inserted, removed, or reordered, remembered child state can follow the position instead of the real item. Give items() a unique, stable key when a clear ID exists.
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(
items = state.categories,
key = { it.id },
contentType = { "category-chip" },
) { category ->
CategoryChip(
category = category,
selected = category.id == state.selectedCategoryId,
onClick = { onAction(CatalogAction.CategorySelected(category.id)) },
)
}
}The lists guide warns that position-based identity can lose item state when a dataset changes. Keys are also important for move animations and scroll-position continuity. Use a real domain ID—not an index or a mutable display label. The next roadmap article covers keys in more depth; do not invent keys if uniqueness is unclear.
contentType is useful when a row has structurally different item layouts, such as a wide promo card and a normal card. Keep the return value small and stable. For a row with just one item shape, it is usually unnecessary.
Give cards a deliberate width and semantics
Horizontal cards need a width constraint; otherwise each item can measure as wide as its content and produce an uneven shelf. Choose a width or an adaptive parent layout that matches the design, then make each card’s primary action one accessible target.
@Composable
fun TopicCard(
topic: TopicUi,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.clickable(
onClickLabel = stringResource(R.string.open_topic),
role = Role.Button,
onClick = onClick,
),
) {
Column(Modifier.padding(16.dp)) {
Text(topic.title, style = MaterialTheme.typography.titleMedium)
Text(topic.summary, style = MaterialTheme.typography.bodyMedium)
}
}
}Prefer Button, AssistChip, or FilterChip when the element is visually and semantically one of those controls. A custom card should have a visible label, an adequate touch target, and a keyboard/TalkBack path. The Compose gesture guide explains why clickable is usually better than raw pointer handling.
Nested horizontal rows are valid—within limits
A vertical feed containing horizontal shelves is a common layout: LazyColumn scrolls vertically and each LazyRow scrolls horizontally. Android’s nested-scrolling guide presents this pattern for catalog and media-style UIs.
LazyColumn {
items(state.shelves, key = { it.id }) { shelf ->
Text(shelf.title, Modifier.padding(horizontal = 16.dp))
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(shelf.items, key = { it.id }) { item ->
ShelfCard(item = item, onClick = { onAction(CatalogAction.ItemOpened(item.id)) })
}
}
}
}Different-direction nesting is supported. Avoid nesting independently scrolling containers in the same direction without a fixed size; the official lists guide calls this out as an invalid pattern. Also keep the number of active shelves reasonable and profile a real catalog rather than assuming a layout is fast.
Control or observe scroll position sparingly
LazyListState is Compose-owned state, so it is appropriate to create with rememberLazyListState() when the row needs programmatic scrolling or observation.
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
LazyRow(state = listState) { /* items */ }
Button(
onClick = { scope.launch { listState.animateScrollToItem(0) } },
) {
Text(stringResource(R.string.back_to_start))
}The official lists guide documents scrollToItem() and animateScrollToItem() as suspending functions. Do not start scroll work directly during composition; trigger it from a user action or an intentional effect. For visibility-derived UI such as a “Back to start” button, use derivedStateOf only when it reduces needless recomposition.
Common mistakes
Using Row for an unbounded feed
Row composes all children. Use LazyRow when the collection can grow, load asynchronously, or contain expensive cards.
Forgetting outer content padding
Gaps between cards do not create a margin at either edge. Use contentPadding so the first card does not touch the screen edge.
Keying items by index
An index changes when data moves. Use a stable domain identifier for dynamic rows.
Making swipe the only way to discover content
Horizontal scrolling needs a visible affordance: partially reveal the next card, use a heading, and ensure focus/keyboard navigation works. Do not hide critical choices off-screen without a clear path.
FAQ
Does LazyRow reuse views like RecyclerView?
It follows lazy-layout principles rather than the View system’s adapter model: it composes and lays out visible content on demand. Use stable keys and appropriate content types so Compose can track changing items correctly.
Should every LazyRow use rememberLazyListState()?
No. The default state is enough unless you need to observe or control scroll position. Keep unnecessary state out of the composition.
Can a LazyRow live inside a LazyColumn?
Yes. Horizontal rows inside a vertical list are a supported, common nested-scroll pattern.
Summary
LazyRow is the practical choice for horizontal collections that can grow. Give it real data, stable item IDs, inside-edge padding, deliberate card sizing, and accessible interactions. Use scroll state only when the product needs it, and keep the row’s job limited to rendering and forwarding events.