Sticky Headers, Headers, and Footers in Compose Lazy Lists

Use stickyHeader() in a LazyColumn when a scrolling collection is grouped and the current group label should remain visible. A regular item {} is better for a non-pinned header or footer. Give data rows and changing headers stable keys, and treat grouping as prepared UI data rather than logic performed inside the list.
The official LazyListScope reference says a sticky header stays pinned while its content scrolls, then is replaced when the next sticky header reaches it. Android’s lazy-lists guide still describes stickyHeader() as experimental, so confirm its annotation in the Compose version used by your app and opt in when required.
Choose the right kind of list content
| Need | Lazy-list DSL |
|---|---|
| Content at the start that scrolls away | item {} before the rows |
| Group label that remains visible while its group scrolls | stickyHeader {} |
| A summary or action after the final row | item {} after the rows |
| Repeated rows | items(...) |
Headers make sense only when they improve orientation. A category label, date, or first letter of a contacts list is useful context; repeating a page title already visible in the app bar is usually noise.
A grouped list with sticky headers
Prepare groups before this composable—normally in the presentation layer—and pass an ordered list. This keeps the UI responsible for layout and click forwarding rather than sorting or grouping application data during composition.
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
data class ContactUi(val id: String, val name: String)
data class ContactGroupUi(val key: String, val contacts: List<ContactUi>)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsList(
groups: List<ContactGroupUi>,
onContactClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(modifier = modifier) {
groups.forEach { group ->
stickyHeader(key = "header-${group.key}") {
Text(
text = group.key,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
items(
items = group.contacts,
key = { contact -> contact.id }
) { contact ->
ContactRow(
contact = contact,
onClick = { onContactClick(contact.id) }
)
}
}
}
}The code labels the header key separately from contact IDs to prevent collisions. The surface background is intentional: a pinned header needs an opaque visual layer so content does not show through it while scrolling.
Add a regular header and footer
Use ordinary item blocks for content that should scroll naturally with the list. They are useful for a filter summary, introductory copy, a loading indicator, an empty-state explanation, or a “load more” control.
LazyColumn {
item(key = "result-summary") {
Text(
text = "24 results",
modifier = Modifier.padding(16.dp)
)
}
items(items = results, key = { it.id }) { result ->
ResultRow(result = result, onClick = { onResultClick(result.id) })
}
item(key = "end-of-results") {
Text(
text = "You have reached the end.",
modifier = Modifier.padding(16.dp)
)
}
}Keep these items in the same lazy container as the data. A separate scrolling column above or below the list often creates awkward nested scrolling and inconsistent padding.
Stable keys and content types
The API accepts key and contentType for sticky headers as well as rows. Use a unique, saveable key for changing content. A key lets Compose maintain the visible item when items are inserted or removed before it; position is only a safe identity when the collection never changes.
For a list mixing headers, normal rows, and a footer, contentType can describe reusable structures:
items(
items = feed,
key = { it.id },
contentType = { item -> item.kind }
) { item ->
when (item.kind) {
FeedKind.Message -> MessageRow(item)
FeedKind.Alert -> AlertRow(item)
}
}The related LazyColumn guide covers list identity and scroll state in more depth. Keep the list’s content model stable and explicit before tuning composition reuse.
Header design and accessibility
A sticky header is not an app bar. It should be compact, readable, and visibly separate from the rows. Ensure the contrast remains clear in light and dark themes, retain enough vertical padding for large text, and avoid interactive controls that unexpectedly remain under a user’s finger while content scrolls beneath them.
For a purely visual group label, normal text semantics are often enough. If the header introduces a real section of navigable content, test it with TalkBack and keyboard navigation. The visible section label should agree with the list order; avoid grouping by a value that is not apparent to the reader.
Common pitfalls
Grouping inside the composable
The documentation’s grouped example notes that grouping is ideally done in the ViewModel. Recomputing large groups inside a composable makes ownership less clear and can add avoidable work during recomposition. Prepare an ordered List<ContactGroupUi> upstream instead.
Transparent pinned headers
Without a background, rows will visually slide through the pinned header. Use an appropriate theme surface and check it in dark mode.
Duplicate or positional keys
Two headers cannot share a key, and a header key must not collide with a row key. Prefixing by item kind is a simple way to make identity unambiguous.
Treating a footer as a paging implementation
A footer can render a loading or retry state, but it should not contain the paging policy. Keep the decision to load, retry, or refresh in the screen state and pass a clear UI state into the list.
Verify the behavior
Check short groups, long groups, an empty group model, large font scaling, dark theme, and insertion before the currently visible row. Scroll through the boundary between two groups: the next header should replace the current one cleanly. If the list is data-heavy, investigate real symptoms with the Compose recomposition guide instead of assuming headers are the bottleneck.
FAQ
Does a sticky header stay pinned forever?
No. It stays pinned while its group’s rows scroll, then the next sticky header replaces it.
Are sticky headers stable?
The current Android guide labels stickyHeader() experimental. Check the exact Compose Foundation version in your project and use the required opt-in if that version still exposes the API as experimental.
Can I use headers and footers in the same LazyColumn?
Yes. Use item {} for normal headers and footers, stickyHeader {} for a pinned group label, and items(...) for repeated rows.