Skip to content
All posts
June 7, 20264 min read

Compose Animations That Improve UX (Not Just Look Cool)

Most animation tutorials focus on what's possible. This one focuses on what's useful: the animations that make an app feel polished and responsive without becoming distracting or slowing users down.

AndroidJetpack ComposeUXKotlin
Share:

Animation is easy to overdo. Apps with constant motion feel exhausting. Apps with zero motion feel dead. The right animations are the ones that help users understand what just changed and why.

Here are the animations worth implementing.


The Purpose Test

Before adding any animation, ask: does this help the user understand what changed? If yes, add it. If it's purely decorative, reconsider.

Good animation purposes:

  • Show that a transition occurred (screen change, item added/removed)
  • Indicate state change (loading → loaded, active → inactive)
  • Guide attention (highlight new content, error state)
  • Provide feedback (button press confirmation, swipe detection)

Animation that gets in the way:

  • Elaborate entry animations users have to wait through every time
  • Motion that plays even when users are trying to move fast
  • Animations that make the UI feel slow (> 300ms for common actions)

1. AnimatedVisibility: Content Appearing and Disappearing

The most useful animation in day-to-day Compose development:

kotlin
// Show/hide with smooth transition
var showFab by remember { mutableStateOf(false) }

AnimatedVisibility(
    visible = showFab,
    enter = fadeIn() + scaleIn(initialScale = 0.8f),
    exit = fadeOut() + scaleOut(targetScale = 0.8f)
) {
    FloatingActionButton(onClick = { addTask() }) {
        Icon(Icons.Default.Add, contentDescription = "Add task")
    }
}

This FAB slides in when a list is scrolled to the top — a common pattern that's expected on Android.

For list items appearing/disappearing:

kotlin
LazyColumn {
    items(tasks, key = { it.id }) { task ->
        AnimatedVisibility(
            visible = true,
            enter = expandVertically() + fadeIn()
        ) {
            TaskItem(task = task)
        }
    }
}

2. animateContentSize: Expanding Content

When content height changes (expandable cards, collapsible sections):

kotlin
var isExpanded by remember { mutableStateOf(false) }

Card(
    modifier = Modifier
        .fillMaxWidth()
        .animateContentSize(
            animationSpec = tween(durationMillis = 200, easing = EaseInOut)
        )
        .clickable { isExpanded = !isExpanded }
) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(text = task.title, style = MaterialTheme.typography.bodyLarge)
        
        if (isExpanded) {
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                text = task.description,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
    }
}

code
animateContentSize()
smoothly resizes the Card as content appears. Without it, the resize is jarring.


3. Animated State: Properties That Change

When individual properties change — color, size, offset:

kotlin
val taskColor by animateColorAsState(
    targetValue = if (task.completed) {
        MaterialTheme.colorScheme.surfaceVariant
    } else {
        MaterialTheme.colorScheme.surface
    },
    animationSpec = tween(durationMillis = 200),
    label = "task_background_color"
)

Surface(
    color = taskColor,
    modifier = Modifier.fillMaxWidth()
) {
    TaskContent(task = task)
}

The task card smoothly transitions to a muted color when completed. This is better UX than an instant color change.

For position/offset:

kotlin
val fabOffset by animateDpAsState(
    targetValue = if (isScrolled) 80.dp else 0.dp,
    label = "fab_offset"
)

FloatingActionButton(
    modifier = Modifier.offset(y = fabOffset)
) { ... }

4. CrossFade: Switching Between States

When the entire content of a container changes:

kotlin
Crossfade(
    targetState = uiState,
    animationSpec = tween(durationMillis = 200),
    label = "content_crossfade"
) { state ->
    when (state) {
        is TaskUiState.Loading -> LoadingSpinner()
        is TaskUiState.Success -> TaskList(tasks = state.tasks)
        is TaskUiState.Error -> ErrorView(message = state.message)
        is TaskUiState.Empty -> EmptyState()
    }
}

Without

code
Crossfade
, switching between loading and content is an abrupt flash. With it, the transition is smooth.


5. Swipe to Dismiss

Swipe-to-delete is a standard Android pattern. Compose provides

code
SwipeToDismissBox
:

kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeableTaskItem(
    task: Task,
    onDismiss: (Task) -> Unit
) {
    val dismissState = rememberSwipeToDismissBoxState(
        confirmValueChange = { dismissValue ->
            if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
                onDismiss(task)
                true
            } else false
        }
    )
    
    SwipeToDismissBox(
        state = dismissState,
        backgroundContent = {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(MaterialTheme.colorScheme.error),
                contentAlignment = Alignment.CenterEnd
            ) {
                Icon(
                    Icons.Default.Delete,
                    contentDescription = "Delete",
                    tint = MaterialTheme.colorScheme.onError,
                    modifier = Modifier.padding(end = 16.dp)
                )
            }
        }
    ) {
        TaskItem(task = task)
    }
}

6. Loading Shimmer Effect

The shimmer loading effect (animated placeholder) is better UX than a spinner for list content:

kotlin
@Composable
fun ShimmerTaskItem() {
    val shimmerColors = listOf(
        MaterialTheme.colorScheme.surface,
        MaterialTheme.colorScheme.surfaceVariant,
        MaterialTheme.colorScheme.surface
    )
    
    val transition = rememberInfiniteTransition(label = "shimmer")
    val translateAnim by transition.animateFloat(
        initialValue = 0f,
        targetValue = 1000f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 1200, easing = LinearEasing)
        ),
        label = "shimmer_translate"
    )
    
    val brush = Brush.linearGradient(
        colors = shimmerColors,
        start = Offset.Zero,
        end = Offset(x = translateAnim, y = translateAnim)
    )
    
    Column(modifier = Modifier.padding(16.dp)) {
        Spacer(
            modifier = Modifier
                .fillMaxWidth()
                .height(16.dp)
                .clip(RoundedCornerShape(4.dp))
                .background(brush)
        )
        Spacer(modifier = Modifier.height(8.dp))
        Spacer(
            modifier = Modifier
                .fillMaxWidth(0.6f)
                .height(12.dp)
                .clip(RoundedCornerShape(4.dp))
                .background(brush)
        )
    }
}

Performance: Keep Animations in the Rendering Layer

Animations that modify properties Compose can handle in the rendering layer (offset, scale, alpha, clip) don't trigger recomposition. They're fast.

Animations that modify layout properties (size, padding) trigger recomposition on every frame. Use them sparingly.

kotlin
// Fast — GPU handles this, no recomposition
.graphicsLayer {
    alpha = animatedAlpha
    scaleX = animatedScale
    scaleY = animatedScale
}

// Slower — triggers recomposition each frame
.size(animatedSize) // Layout change

Takeaways

  • Apply the purpose test before adding any animation — does it help the user understand something?
  • code
    AnimatedVisibility
    for content appearing/disappearing,
    code
    Crossfade
    for state transitions
  • code
    animateColorAsState
    ,
    code
    animateDpAsState
    for smooth property transitions
  • code
    animateContentSize()
    for expandable content — free to use, significant UX improvement
  • Keep animations under 200-300ms for common interactions — speed is also UX
  • Use
    code
    .graphicsLayer {}
    for transform-only animations — avoids recomposition overhead
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this