Android Shared Element Transitions — Smooth Animations Between Screens with Kotlin

You know that animation where you tap a photo in a list and it smoothly expands into a full-screen detail view — like Google Photos or the Play Store? That's a Shared Element Transition. It creates a visual continuity between two screens that tells the user "this is the same thing, just bigger" without a single word of explanation.

It's one of those details that separates apps that feel polished from apps that feel functional. And it's surprisingly achievable — once you know the three gotchas that cause it to break silently. This guide covers Activity transitions, Fragment transitions, RecyclerView integration, and the critical postponeEnterTransition() trick you need when images load asynchronously.

Android Shared Element Transition animation example

Shared Element Transition — the image flies from the list item to the detail screen seamlessly

How It Works

The system needs to know which view in Screen A corresponds to which view in Screen B. You tell it by giving both views the same transitionName. When you navigate, Android animates the view from its position in Screen A to its position in Screen B. On back press, it reverses.

Three things must be true for it to work:

  1. Both views must have the same transitionName
  2. The transition name must be unique on screen — if you have a list, each item needs a unique name
  3. If images load asynchronously, you must postpone the transition until they're ready

Activity to Activity — Basic Setup

Step 1: Set transitionName in both layouts

<!-- activity_list.xml — source view -->
<ImageView
    android:id="@+id/imgUser"
    android:layout_width="80dp"
    android:layout_height="80dp"
    android:transitionName="user_image" />
<!-- activity_detail.xml — destination view -->
<ImageView
    android:id="@+id/imgUserDetail"
    android:layout_width="match_parent"
    android:layout_height="300dp"
    android:transitionName="user_image" />

Step 2: Start Activity with the transition

// In your source Activity — Kotlin
val intent = Intent(this, DetailActivity::class.java)
intent.putExtra("userId", user.id)

val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
    this,
    binding.imgUser,    // the view to animate
    "user_image"        // must match transitionName in both layouts
)

startActivity(intent, options.toBundle())

Step 3: Enable return transition in the detail Activity

// In DetailActivity — enable the return animation on back press
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // Must be called BEFORE setContentView
    window.requestFeature(Window.FEATURE_ACTIVITY_TRANSITIONS)
    setContentView(R.layout.activity_detail)
}
💡 Material themes handle this automatically. If your app uses Theme.MaterialComponents or Theme.Material3, you don't need to call requestFeature or add windowContentTransitions to styles.xml. It's enabled by default.

The Most Important Part — Postponing for Async Image Loading

Here's the gotcha that nobody explains clearly. If you load images with Glide or Coil, the image isn't ready when the transition starts. The animation plays against an empty ImageView and looks broken — the element just appears instead of flying in.

The fix is postponeEnterTransition() — tell Android to wait before starting the animation, then call startPostponedEnterTransition() once the image is loaded:

// In DetailActivity
class DetailActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_detail)

        // Postpone until image is ready
        postponeEnterTransition()

        val userId = intent.getStringExtra("userId")

        Glide.with(this)
            .load(getUserImageUrl(userId))
            .listener(object : RequestListener<Drawable> {
                override fun onResourceReady(
                    resource: Drawable, model: Any, target: Target<Drawable>?,
                    dataSource: DataSource, isFirstResource: Boolean
                ): Boolean {
                    // Image loaded — start the transition now
                    startPostponedEnterTransition()
                    return false
                }
                override fun onLoadFailed(
                    e: GlideException?, model: Any?,
                    target: Target<Drawable>, isFirstResource: Boolean
                ): Boolean {
                    // Image failed — start anyway to avoid freezing the screen
                    startPostponedEnterTransition()
                    return false
                }
            })
            .into(binding.imgUserDetail)
    }
}
⚠️ Always call startPostponedEnterTransition() in onLoadFailed too. If you only call it on success and the image fails to load, the screen will freeze indefinitely. Add a timeout as a safety net: window.sharedElementEnterTransition.duration = 400L

RecyclerView to Detail — The Real World Use Case

This is where most developers actually need it. A list of users, articles, or products — tap one, it expands into a detail screen. The key is giving each item a unique transition name using the item's ID:

In your Adapter

class UserAdapter(
    private val users: List<User>,
    private val onItemClick: (User, View) -> Unit
) : RecyclerView.Adapter<UserAdapter.ViewHolder>() {

    inner class ViewHolder(private val binding: ItemUserBinding)
        : RecyclerView.ViewHolder(binding.root) {

        fun bind(user: User) {
            // CRITICAL: unique transition name per item using item ID
            ViewCompat.setTransitionName(binding.imgUser, "user_image_${user.id}")

            Glide.with(binding.root)
                .load(user.imageUrl)
                .into(binding.imgUser)

            binding.root.setOnClickListener {
                onItemClick(user, binding.imgUser)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        ViewHolder(ItemUserBinding.inflate(LayoutInflater.from(parent.context), parent, false))

    override fun onBindViewHolder(holder: ViewHolder, position: Int) =
        holder.bind(users[position])

    override fun getItemCount() = users.size
}

In your Fragment or Activity launching the detail

// Pass the unique transition name to the detail screen
userAdapter = UserAdapter(users) { user, imageView ->
    val intent = Intent(this, DetailActivity::class.java).apply {
        putExtra("userId", user.id)
        // Pass the transition name so detail screen can set it on its ImageView
        putExtra("transitionName", ViewCompat.getTransitionName(imageView))
    }

    val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
        this, imageView, ViewCompat.getTransitionName(imageView) ?: ""
    )
    startActivity(intent, options.toBundle())
}

In DetailActivity — apply the received transition name

class DetailActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_detail)

        postponeEnterTransition()

        // Apply the transition name received from the list
        val transitionName = intent.getStringExtra("transitionName")
        ViewCompat.setTransitionName(binding.imgUserDetail, transitionName)

        // Load image and start transition when ready
        Glide.with(this)
            .load(getUserImageUrl(intent.getStringExtra("userId")))
            .listener(object : RequestListener<Drawable> {
                override fun onResourceReady(resource: Drawable, model: Any,
                    target: Target<Drawable>?, dataSource: DataSource,
                    isFirstResource: Boolean): Boolean {
                    startPostponedEnterTransition()
                    return false
                }
                override fun onLoadFailed(e: GlideException?, model: Any?,
                    target: Target<Drawable>, isFirstResource: Boolean): Boolean {
                    startPostponedEnterTransition()
                    return false
                }
            })
            .into(binding.imgUserDetail)
    }
}

Fragment to Fragment Transitions

Fragment transitions use the Navigation Component and work slightly differently — you set the transition on the Fragment itself rather than the Intent:

// In your list Fragment — navigate to detail with shared element
class UserListFragment : Fragment() {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        userAdapter = UserAdapter(users) { user, imageView ->
            // Set the transition name to match the detail fragment
            val extras = FragmentNavigatorExtras(
                imageView to "user_image_${user.id}"
            )
            val action = UserListFragmentDirections
                .actionListToDetail(userId = user.id)

            findNavController().navigate(action, extras)
        }
    }
}
// In DetailFragment — receive the transition
class UserDetailFragment : Fragment() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Set the enter transition
        sharedElementEnterTransition = TransitionInflater.from(requireContext())
            .inflateTransition(android.R.transition.move)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        postponeEnterTransition()

        val userId = args.userId
        ViewCompat.setTransitionName(binding.imgUserDetail, "user_image_$userId")

        Glide.with(this)
            .load(getUserImageUrl(userId))
            .listener(object : RequestListener<Drawable> {
                override fun onResourceReady(resource: Drawable, model: Any,
                    target: Target<Drawable>?, dataSource: DataSource,
                    isFirstResource: Boolean): Boolean {
                    startPostponedEnterTransition()
                    return false
                }
                override fun onLoadFailed(e: GlideException?, model: Any?,
                    target: Target<Drawable>, isFirstResource: Boolean): Boolean {
                    startPostponedEnterTransition()
                    return false
                }
            })
            .into(binding.imgUserDetail)
    }
}

Multiple Shared Elements

You can animate more than one element simultaneously — for example an image and a title text together:

// Animate both image and title text
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
    this,
    Pair(binding.imgUser as View, "user_image"),
    Pair(binding.tvUserName as View, "user_name")
)
startActivity(intent, options.toBundle())
<!-- In detail layout — same transition names -->
<ImageView
    android:transitionName="user_image" ... />

<TextView
    android:transitionName="user_name" ... />

Best Practices

  • Always use unique transition names in lists — use the item ID in the name: "user_image_${user.id}". If two visible items share the same transition name, Android picks one randomly and the wrong element animates.
  • Always postpone when loading images asyncpostponeEnterTransition() in onCreate(), startPostponedEnterTransition() in onResourceReady() AND onLoadFailed(). Missing the failure case freezes the screen.
  • Keep transitions short — 300–400ms is the sweet spot. Longer than 500ms starts to feel sluggish. Set it with window.sharedElementEnterTransition.duration = 350L.
  • Set transitionName in code for dynamic content — in RecyclerView, always set it in onBindViewHolder using ViewCompat.setTransitionName() rather than in XML, because the same ViewHolder is reused for different items.
  • Test the return transition too — press back after the enter transition and verify the return animation works correctly. A common issue is the image returning to the wrong position if the RecyclerView has scrolled.

Frequently Asked Questions

How do I implement Shared Element Transition with Kotlin?
Set the same android:transitionName on both views, then use ActivityOptionsCompat.makeSceneTransitionAnimation() with the source view and name. For async images, call postponeEnterTransition() and startPostponedEnterTransition() after the image loads.

Why is my transition not working with Glide?
The transition fires before Glide finishes loading. Call postponeEnterTransition() before setContentView(), then startPostponedEnterTransition() inside onResourceReady() AND onLoadFailed() — both are required.

How do I use this with RecyclerView?
Set a unique transition name per item in onBindViewHolder using ViewCompat.setTransitionName(view, "prefix_${item.id}"). Pass the name to the detail screen via Intent. Apply the same name to the destination view there.

How do I do Fragment-to-Fragment shared element transitions?
Use FragmentNavigatorExtras mapping the source view to its transition name, pass extras to NavController.navigate(). In the destination Fragment, set sharedElementEnterTransition in onCreate() and postpone until async content loads.

📝 Summary
  • Both views must have the same transitionName — that's all Android needs to connect them
  • Use unique names in RecyclerView"prefix_${item.id}" set in onBindViewHolder
  • Always postpone for async image loading — postponeEnterTransition() + startPostponedEnterTransition()
  • Call startPostponedEnterTransition() in both onResourceReady() and onLoadFailed()
  • For Fragment transitions use FragmentNavigatorExtras with Navigation Component
  • Multiple elements — use Pair(view, name) overload of makeSceneTransitionAnimation()
  • Keep duration 300–400ms — longer feels sluggish

Pragnesh Ghoda

A forward-thinking developer offering more than 8 years of experience building, integrating, and supporting android applications for mobile and tablet devices on the Android platform. Talks about #kotlin and #android

1 Comments

Please let us know about any concerns or query.

Previous Post Next Post

Contact Form