You cannot fade out something that has already left the composition
An element removed by an if is gone immediately, so an exit animation has nothing to animate. AnimatedVisibility keeps it composed until the exit finishes — and AnimatedContent does the same for swaps.

Day 64 of 100. The animation problem that value-based APIs structurally cannot solve, and the two components that exist because of it.
The symptom
A banner that fades in and vanishes instantly:
val alpha by animateFloatAsState(if (showBanner) 1f else 0f, label = "alpha")
if (showBanner) {
Banner(Modifier.graphicsLayer { this.alpha = alpha })
}
Appearing works — the composable enters, alpha animates 0 → 1. Disappearing doesn't: the
moment showBanner becomes false, the if stops emitting Banner, so it leaves the
composition immediately. The alpha animation has nothing left to animate.
Day 8 named the mechanism: leaving the composition is not something a value can defer.
Why the obvious fix fails
The obvious fix is to keep it composed and rely on alpha:
Banner(Modifier.graphicsLayer { this.alpha = alpha }) // always composed
It fades correctly and the banner is still there — occupying layout space, receiving touches, and read aloud by screen readers. An invisible element that intercepts taps is a worse bug than one that vanishes abruptly.
Adding if (alpha > 0f) around it brings back the original problem, one frame later.

The actual mechanism
AnimatedVisibility owns the composition lifecycle:
AnimatedVisibility(
visible = showBanner,
enter = fadeIn() + slideInVertically { -it },
exit = fadeOut() + slideOutVertically { -it },
) {
Banner()
}
When visible becomes false it keeps the content composed, runs the exit animation,
and only then removes it. That deferral is the entire reason the component exists, and
it's not something you can implement with a value.
Enter and exit transitions compose with +:
fadeIn() + expandVertically()
slideInHorizontally { it } + scaleIn()
The available pieces are fadeIn/fadeOut, slideIn/slideOut (and the axis-specific
variants), expandIn/shrinkOut (which animate the size, affecting layout), and
scaleIn/scaleOut (which don't).
The distinction between expand and scale is the one that matters: expandVertically
changes the space the element occupies, so siblings move; scaleIn changes only the
drawing, so the layout is reserved from the start. For a list item appearing, you want
expand; for a dialog, scale.
Lazy list items get their own modifier
A related case the component doesn't cover: items appearing, disappearing and reordering
inside a LazyColumn. AnimatedVisibility around an item animates its visibility, and
does nothing about the items below it moving up.
LazyColumn {
items(messages, key = { it.id }) { message ->
MessageRow(message, Modifier.animateItem())
}
}
Modifier.animateItem() animates placement, appearance and removal as the list changes.
It requires the key from Day 23 — without stable identity the list can't tell a move
from a replacement, which is the same requirement in a new costume.
That single modifier is what makes an inbox feel alive when a message arrives, and it's routinely missed because the list already works without it.
MutableTransitionState, for entrance and completion
Two things the boolean form can't do: animate on first composition, and tell you when the exit finished.
val state = remember { MutableTransitionState(false).apply { targetState = true } }
AnimatedVisibility(visibleState = state, enter = fadeIn(), exit = fadeOut()) {
Banner()
}
LaunchedEffect(state.isIdle, state.currentState) {
if (state.isIdle && !state.currentState) onBannerFullyRemoved()
}
isIdle means no animation is running; currentState is the settled value. The pair
answers "is it fully gone yet", which matters when the removal should trigger something —
releasing a resource, or advancing a queue of messages.
AnimatedContent, for swaps
When content is replaced rather than shown or hidden:
AnimatedContent(
targetState = count,
transitionSpec = {
if (targetState > initialState) {
slideInVertically { it } + fadeIn() togetherWith
slideOutVertically { -it } + fadeOut()
} else {
slideInVertically { -it } + fadeIn() togetherWith
slideOutVertically { it } + fadeOut()
}
},
label = "count",
) { targetCount ->
Text("$targetCount", style = MaterialTheme.typography.headlineLarge)
}
Both the outgoing and incoming content are composed simultaneously during the transition —
togetherWith pairs the enter and exit. The direction check is what makes an incrementing
counter roll up and a decrementing one roll down, which is a small detail that reads as
polish.
Two things to get right:
Use the lambda parameter, not the outer state. The content lambda receives
targetCount; using the captured count instead means the outgoing copy renders the new
value while animating out, which looks like a glitch.
SizeTransform controls the container. By default the container animates between the
two content sizes. SizeTransform(clip = false) stops it clipping during the change, and
null disables the size animation entirely.
Nesting inside a transition
Both components have Transition extension forms — Day 63's point. Inside a larger state
change, use those so the timing is shared:
transition.AnimatedVisibility(visible = { it != State.Loading }) { Content() }
The standalone versions run their own clock, so an element appearing as part of a screen transition would animate independently of everything else in it.
The accessibility consequence
Worth stating because it's the reason to prefer these over the always-composed hack: when
AnimatedVisibility removes content, it leaves the semantics tree too. A screen reader
stops seeing it, focus can't land on it, and tests stop finding it.
The alpha-only version keeps all of that — an invisible node that is focusable, findable and tappable. That's not a subtle difference; it's a screen a keyboard user can get stuck in.
How to prove it
The exit-animation claim is directly testable:
@Test fun bannerAnimatesOut() = runComposeUiTest {
mainClock.autoAdvance = false
var visible by mutableStateOf(true)
setContent { AnimatedVisibility(visible) { Banner(Modifier.testTag("banner")) } }
visible = false
mainClock.advanceTimeBy(50)
onNodeWithTag("banner").assertExists() // still there, animating out
mainClock.advanceTimeBy(1000)
onNodeWithTag("banner").assertDoesNotExist() // now gone
}
Against the if version, the first assertion fails immediately — which is the bug, made
into a test. It's a rare case where the failing test is more legible than a
description of the problem: the node is gone at 50ms, and it should not be.
mainClock.autoAdvance = false is doing the work here, and it's tomorrow's subject in
full.
What this generalizes to
The principle: an element's presence is itself animatable state, and only something that owns the lifecycle can animate it. Values can animate properties of things that exist. Existence is a different question, and it needs a component that can hold the element past the moment your state says it's gone.
Every declarative UI framework grows this component — React has transition groups, Vue has
<Transition>, SwiftUI has transitions on conditional views — and always for the same
reason. Removal is instant, and animation takes time.
The general form of the lesson is that declarative systems need an explicit place to put "not yet". Anywhere state says something is gone but the interface needs a moment to catch up — an exiting element, a closing sheet, a dismissed notification — something has to own that interval. Making it a component rather than a flag is what keeps it from leaking into every call site.
Tomorrow, Day 65: shared element transitions — animating an element between two screens.
Day 64 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: AnimatedVisibility.