Animate Lazy List Item Changes in Jetpack Compose

Quick answer: give every changing lazy-list item a stable, unique key, then place
Modifier.animateItem()on the item’s root composable. Compose can then animate additions, removals, and position changes when the list data is replaced, filtered, sorted, or reordered.
animateItem() is the lazy-layout modifier for structural list motion. It is designed for the changes users notice in a task list, search result, feed, or shopping cart: an item appears, disappears, or shifts to a different position.
The modifier is simple, but its dependency on identity is not optional. Compose must know that “task 42 moved” rather than “the row at index 3 changed.” That is why stable keys come first.
The two requirements
An animated lazy list needs both of these:
| Requirement | What it solves |
|---|---|
| A new observable list state when data changes | Gives Compose a new list order or membership to render. |
| A stable, unique item key | Lets the lazy layout match each item before and after the change. |
The official lazy-list guide specifically calls for keys so animateItem() can find an item’s new position. A list index is not a stable key when filtering, inserting, or sorting can change it.
For a fuller identity discussion, see Lazy list keys: preserve item state and animations. This article focuses on the motion once identity is already correct.
The minimal pattern
Place animateItem() inside the items content lambda, on the item root:
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.animateItem
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
data class TaskUi(
val id: String,
val title: String,
val createdAtEpochMillis: Long,
)
@Composable
fun AnimatedTaskList(
tasks: List<TaskUi>,
onTaskClick: (String) -> Unit,
) {
LazyColumn {
items(
items = tasks,
key = { task -> task.id },
) { task ->
TaskRow(
task = task,
onClick = { onTaskClick(task.id) },
modifier = Modifier
.fillMaxWidth()
.animateItem(),
)
}
}
}With its default specs, animateItem() handles fade-in, fade-out, and placement changes. The LazyItemScope.animateItem reference confirms all three behaviors and notes that the modifier requires keys to enable animations.
animateItem() is available from the LazyItemScope supplied by items, item, and similar lazy DSL calls. It is not a general-purpose modifier for a regular Column.
Drive it from immutable UI state
The animation responds to a new list. Keep the collection in a state owner and replace or derive it when a user sorts, filters, inserts, or removes an item.
enum class TaskOrder { Title, Newest }
fun orderTasks(
tasks: List<TaskUi>,
order: TaskOrder,
): List<TaskUi> = when (order) {
TaskOrder.Title -> tasks.sortedBy { it.title }
TaskOrder.Newest -> tasks.sortedByDescending { it.createdAtEpochMillis }
}For a real app, a ViewModel normally exposes the ordered List<TaskUi> in its screen state. A sorting control emits an event, the state owner produces the new order, and LazyColumn renders it. The list animation belongs in the composable; the sort rule and task data do not.
Avoid in-place mutations that leave state observers unaware of a list change. The important visible input is a new state value with the same durable IDs in a different order or with different membership.
Tune each part of the motion
You can supply separate finite specs for entering, leaving, and moving. This is useful when a screen needs a brief fade for removal but a softer placement spring for reordered neighbors.
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.lazy.animateItem
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
val itemMotion = Modifier.animateItem(
fadeInSpec = tween(durationMillis = 180),
placementSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = IntOffset.VisibilityThreshold,
),
fadeOutSpec = tween(durationMillis = 120),
)The placement spec animates an IntOffset, not a Float, because the lazy layout is moving an item through its measured position. Pass null for any of the three specs when that part should occur without animation.
Do not add a bouncy spring just because list movement is animated. A small local reorder can tolerate it; a dense feed where several rows move at once usually reads better with a restrained spring or short tween. The goal is to preserve spatial context, not make every data refresh perform.
For the differences between springs, tweens, and keyframes, read Spring, Tween, Keyframes, and AnimationSpec Explained.
Add, remove, and reorder are data operations
The modifier does not create list changes; it presents changes your state already describes.
// Add a new task at the beginning.
tasks = listOf(newTask) + tasks
// Remove one task by its stable ID.
tasks = tasks.filterNot { it.id == removedTaskId }
// Reorder without changing task identity.
tasks = tasks.sortedBy { it.title }When an ID survives the operation, Compose can animate that same item to its new position. When an ID disappears, it can run the exit motion. When an entirely new ID appears, it can run the enter motion.
If a user swipes an item away, wait for the dismiss interaction to be confirmed, then remove it from the parent state. The Animatable gesture guide explains how a custom drag can report that confirmed outcome; animateItem() then makes the remaining list close the gap.
Why a key is more important than the modifier
This code looks nearly identical but is wrong for a list that changes order:
LazyColumn {
items(tasks) { task ->
TaskRow(
task = task,
modifier = Modifier.animateItem(),
)
}
}Without key = { it.id }, position is the identity. After sorting, Compose cannot reliably distinguish a moved task from the task now occupying that slot. The result can be missing placement animation, remembered row state associated with the wrong item, or both.
Keys must be unique within the same lazy layout and should be durable and Android-saveable when you need rememberSaveable state in rows. Do not use the list index, mutable title text, or a random value created during composition.
Animate structural changes, not every row property
animateItem() moves the item container when its membership or placement changes. It does not decide how the contents of a still-present row should change from “incomplete” to “complete.” Keep those concerns separate:
- Use
animateItem()when list items enter, leave, or move. - Use
animate*AsStatefor one changing value in a row, such as alpha or color. - Use
AnimatedContentwhen a row swaps meaningful content states. - Use
animateContentSizewhen the size of a stable row expands or collapses.
This separation prevents a list reordering from becoming tightly coupled to every visual change inside each item.
Common mistakes
Using an index as the key
An index changes when an item is inserted, removed, or sorted—the exact moments placement animation needs identity. Use a model ID instead.
Putting animateItem() below the root that moves
Apply it to the outer composable that represents the lazy item. If only a child label has the modifier, the row’s placement may not produce the motion you expect.
Removing state before the dismiss decision is final
If a swipe can be cancelled or returned to origin, deleting the data during the drag creates an irreversible interaction. Confirm the dismissal, update parent state, then let the lazy list animate the structural removal.
Animating a large refresh without a product reason
Animating hundreds of moving rows after every server refresh can be visually noisy. Use animateItem() for user-meaningful changes—sorting, moving, adding, removing—or choose quiet specs that fit the screen.
Expecting it to fix scroll performance
This modifier clarifies structural changes; it is not a general scrolling-performance switch. For mixed row structures, use contentType appropriately and measure actual behavior. See contentType in LazyColumn.
Test the transition users will perform
- Add an item at the top while the list is partially scrolled.
- Remove an item from the middle and confirm neighboring rows close the gap.
- Sort and reverse-sort the same stable IDs.
- Filter an item out and restore it.
- Test an interrupted swipe, rapid repeated sorting, large font, and reduced animation scale.
For automated UI tests, assert the resulting list order and domain event first. Add visual or screenshot coverage only where intermediate movement is an important product requirement; timing assertions alone tend to be fragile.
FAQ
Does animateItem() animate removals as well as reordering?
Yes. It supports appearance, disappearance, and placement changes. Use stable keys so Compose can identify the item involved in the operation.
Can I use animateItem() in LazyRow or grids?
The same lazy-item concept applies to lazy rows and the lazy grid/staggered-grid item scopes that expose the modifier. Check the scope and Foundation version in your project; this article’s examples use LazyColumn.
Why does my reorder jump instead of animate?
First check that every dynamic item has a unique, stable key and that the root of each item uses animateItem(). Then confirm your data really changes order rather than replacing IDs.
Summary
animateItem() turns meaningful list mutations into understandable movement. Keep list state in a state owner, retain stable IDs, place the modifier on the item root, and tune only the enter, exit, or placement motion that improves context for the user.