Documentation

transition:crossfade

Create smooth shared element transitions between screens with matching keys.

Overview

Shared element transitions create visual continuity as you navigate between screens. An element on one screen morphs into the corresponding element on another screen.

Basic Usage

Add the same transition:crossfade key to elements on both screens:

List Screen

<LazyColumn>
  @for (movie in movies) {
    <Card key={movie.id} onClick={() => $navigate("/movie/{movie.id}")}>
      <Image
        key={movie.id}
        src={movie.poster}
        transition:crossfade="poster-{movie.id}"
      />
      <Text>{movie.title}</Text>
    </Card>
  }
</LazyColumn>

Detail Screen

<Column>
  <Image
    src={$data.posterUrl}
    transition:crossfade="poster-{$route.params.id}"
  />
  <Text>{$data.title}</Text>
  <Text>{$data.description}</Text>
</Column>

The crossfade key must be identical on both screens. Use dynamic values like IDs to ensure each item has a unique, matching key.

Configuration

Customize the transition with an options object:

<Image
  transition:crossfade={{
    key: "poster-{id}",
    duration: 400,
    type: "bounds"
  }}
/>

Options

OptionTypeDefaultDescription
keyStringRequiredUnique identifier to match elements
typeString"element""element" or "bounds" - animation style
durationNumber300Animation duration in milliseconds

Transition Types

  • "element" - Animates the element itself (recommended for images, icons)
  • "bounds" - Animates only position and size (recommended for containers)

Asymmetric Transitions

Use different durations for enter and exit:

<Image
  in:crossfade={{ key: "poster-{id}", duration: 400 }}
  out:crossfade={{ key: "poster-{id}", duration: 150 }}
/>

See it happen

Switch variants to compare the shorthand, the config object, and asymmetric enter/exit timing.

Poster.wh
val id = movie.id

<Card p={16}>
  <Image src={movie.poster} transition:crossfade="poster-${id}" w={100} h={150} />
  <Text>{movie.title}</Text>
</Card>
Kotlin waiting
Scroll into view to compile.

The shorthand takes just the key — everything else uses the defaults (element type, 300ms).

Examples

Product Gallery

// Products list
<LazyVerticalGrid columns={2}>
  @for (product in products) {
    <Card key={product.id} onClick={() => $navigate("/product/{product.id}")}>
      <Image
        key={product.id}
        src={product.image}
        transition:crossfade="product-{product.id}"
      />
      <Text>{product.name}</Text>
    </Card>
  }
</LazyVerticalGrid>

// Product detail
<Column>
  <Image
    src={$data.image}
    transition:crossfade="product-{$route.params.id}"
    h={400}
  />
  <Text fontSize={24}>{$data.name}</Text>
  <Text>{$data.description}</Text>
</Column>

User Avatar

// User list
@for (user in users) {
  <Row key={user.id} onClick={() => $navigate("/user/{user.id}")}>
    <Image
      key={user.id}
      src={user.avatar}
      transition:crossfade="avatar-{user.id}"
      w={48} h={48}
      cornerRadius={24}
    />
    <Text>{user.name}</Text>
  </Row>
}

// User profile
<Column>
  <Image
    src={$data.avatar}
    transition:crossfade="avatar-{$route.params.id}"
    w={120} h={120}
    cornerRadius={60}
  />
  <Text fontSize={32}>{$data.name}</Text>
</Column>

Multiple Shared Elements

Animate multiple elements on the same screen pair:

// Article list
<Card onClick={() => $navigate("/article/{article.id}")}>
  <Image
    src={article.cover}
    transition:crossfade="cover-{article.id}"
  />
  <Text
    transition:crossfade="title-{article.id}"
  >
    {article.title}
  </Text>
</Card>

// Article detail
<Column>
  <Image
    src={$data.cover}
    transition:crossfade="cover-{$route.params.id}"
    h={300}
  />
  <Text
    fontSize={28}
    transition:crossfade="title-{$route.params.id}"
  >
    {$data.title}
  </Text>
  <Text>{$data.body}</Text>
</Column>

With Navigation State

Pass preview data for instant rendering:

// List (pass preview data)
<Card onClick={() => $navigate(
  "/show/{show.id}",
  state = { poster: show.poster, title: show.name }
)}>
  <Image
    src={show.poster}
    transition:crossfade="poster-{show.id}"
  />
</Card>

// Detail (use preview until loaded)
suspend fun load(): ShowData {
  val preview = $route.state
  val show = $fetch.get(url = "...")
  return ShowData(show)
}

<Image
  src={$route.state?.poster ?: $data.posterUrl}
  transition:crossfade="poster-{$route.params.id}"
/>

Best Practices

  • Use descriptive, unique keys: "poster-{id}", not just "{id}"
  • Keep duration short (300-400ms) for snappy feel
  • Use type="element" for images, type="bounds" for containers
  • Combine with navigation state for smooth loading states
  • Limit to 1-3 shared elements per screen transition

Shared element transitions work with Compose's predictive back gesture. Users can scrub the animation by dragging the back gesture.

See Also