Insight

January 6, 2026

Mastering Pagers in Android Jetpack Compose

Mastering Pagers in Android Jetpack Compose

For years, the ViewPager and its successor, ViewPager2, were the standard-bearers for creating swippable layouts in Android development. Using the traditional XML-based system, implementing a carousel or a multi-tab onboarding flow required a significant amount of boilerplate: defining FragmentStateAdapter or PagerAdapter classes, managing fragment lifecycles, and carefully hooking into XML layouts.

For years, the ViewPager and its successor, ViewPager2, were the standard-bearers for creating swippable layouts in Android development. Using the traditional XML-based system, implementing a carousel or a multi-tab onboarding flow required a significant amount of boilerplate: defining FragmentStateAdapter or PagerAdapter classes, managing fragment lifecycles, and carefully hooking into XML layouts.

With the shift toward Jetpack Compose, the need for a more intuitive, declarative way to handle horizontal and vertical swiping became apparent. The Compose Pager — now part of the stable Foundation libraries — replaces the rigid adapter-based system with a flexible, state-driven model. Instead of juggling adapters and fragments, developers can now define paginated content using simple Composable functions. This not only aligns with the modern reactive programming paradigm but also makes features like synchronized animations, custom page transformations, and dynamic content loading significantly easier to implement. With the inherent flexibility of Compose, we can do more with far less effort, turning what used to be a complex architectural task into a concise and readable implementation.

Getting Started: The Setup

To use Pagers in your project, ensure you are using a modern version of the Compose BOM (Bill of Materials). As of January 4, 2026, the latest stable release is:

// In your build.gradle.ktsdependencies {    val composeBom = "2025.12.01"    implementation(platform("androidx.compose:compose-bom:$composeBom"))    implementation("androidx.compose.foundation:foundation")    // ... other dependencies

For better scalability and readability, using Version Catalogs (libs.versions.toml) is highly recommended in modern Android projects. This centralizes your dependency management and ensures consistency across multi-module projects. By including the Foundation library via your preferred setup, you gain access to the HorizontalPager and VerticalPager composables.

Basic Implementation

The core of a Pager is the PagerState, which tracks which page is currently visible and handles the scrolling logic. You initialize this using rememberPagerState.

Here is how you can implement a simple horizontal swipe view:

@Composablefun HorizontalPagerView() {    Box {        // Define the number of pages (e.g., 3)        val state = rememberPagerState { 3 }                HorizontalPager(            modifier = Modifier.fillMaxSize(),            state = state        ) { page ->            PagerItem(                modifier = Modifier.fillMaxSize(),                page = page


If your design requires vertical scrolling — common in full-screen video feeds or document viewers — you simply swap the component for a VerticalPager:

@Composablefun VerticalPagerView() {    Box {        val state = rememberPagerState { 3 }                VerticalPager(            modifier = Modifier.fillMaxSize(),            state = state        ) { page ->            PagerItem(                modifier = Modifier.fillMaxSize(),                page = page


Deep Dive into PagerState

Both HorizontalPager and VerticalPager rely on PagerState to function. This state object is incredibly powerful; it allows you to listen to the current screen, define the total page count, set the starting page, and even programmatically trigger page changes.

Here is the official signature for the rememberPagerState function:

@Composablefun rememberPagerState(    initialPage: Int = 0,    @FloatRange(from = -0.5, to = 0.5) initialPageOffsetFraction: Float = 0f,    pageCount: () -> Int,): PagerState

In most use cases, you’ll only need to provide the initialPage and the pageCount lambda. The initialPageOffsetFraction is best left at its default 0f unless you have a specific requirement for the initial scroll alignment.

Controlling the Pager Programmatically

Because Compose is state-driven, you can easily add external controls like “Next” and “Previous” buttons. Since scrolling to a page is a “suspend” operation (it involves an animation over time), you’ll need a CoroutineScope to trigger these actions.

@Composableprivate fun BoxScope.ControlButtons(state: PagerState) {    Row(        modifier = Modifier            .fillMaxWidth()            .align(Alignment.BottomCenter)            .padding(16.dp),    ) {        val coroutineScope = rememberCoroutineScope()        IconButton(            onClick = {                coroutineScope.launch {                    state.animateScrollToPage(max(state.currentPage - 1, 0))                }            },        ) {            Icon(                painter = painterResource(R.drawable.baseline_arrow_back_24),                contentDescription = "Back"            )        }        Spacer(modifier = Modifier.weight(1f))        IconButton(            onClick = {                coroutineScope.launch {                    state.animateScrollToPage(min(state.currentPage + 1, state.pageCount - 1))                }            },        ) {            Icon(                painter = painterResource(R.drawable.outline_arrow_forward_24),                contentDescription = "Forward"


Maintaining State with Keys

When dealing with dynamic content — where items might be added, removed, or reordered — using the key parameter is essential. By default, the Pager uses the item's position as its key. However, if you insert an item at the beginning of your list, the positions of all subsequent items change, which can lead to the scroll position jumping or state being lost.

By providing a stable and unique key, you ensure the scroll position is maintained based on that key. If items are added or removed before the current visible item, the Pager uses the key to keep the correct item visible.

@Composablefun HorizontalPagerView() {    Box(        modifier = Modifier            .fillMaxSize()            .background(Color.White)    ) {        val state = rememberPagerState { 10 }        HorizontalPager(            modifier = Modifier.fillMaxSize(),            state = state,            // Provide a unique key for each page            key = { page -> "page_$page" },        ) { page ->            PagerItem(                page = page,                modifier = Modifier                    .fillMaxWidth()                    .height(200.dp

Controlling Page Size

By default, a Pager assumes that its content should fill the entire available space in its scroll axis.

@Composablefun HorizontalPagerView() {    Box(        modifier = Modifier            .fillMaxSize()            .background(Color.White)    ) {        val state = rememberPagerState { 50 }        HorizontalPager(            modifier = Modifier.fillMaxSize(),            state = state,        ) {            PagerItem(                page = it,                modifier = Modifier                    .fillMaxWidth()                    .height(200.dp


While it might seem like this behavior is caused by fillMaxWidth(), the issue is deeper. Even if you assign a fixed width (e.g., 200.dp) to the page content, the Pager still treats that element as the 'full page' unit, stretching the layout or alignment to match the pager's swipe boundaries

@Composablefun HorizontalPagerView() {    Box(        modifier = Modifier            .fillMaxSize()            .background(Color.White)    ) {        val state = rememberPagerState { 50 }        HorizontalPager(            modifier = Modifier.fillMaxSize(),            state = state,        ) {            PagerItem(                page = it,                modifier = Modifier                    .width(200.dp)                    .height(200.dp


This behavior exists because the pageSize parameter defaults to PageSize.Fill. To display multiple items or items with a specific size, you must change this parameter to PageSize.Fixed

HorizontalPager(  ...  pageSize = PageSize.Fixed(200.dp


or a custom implementation.

private val threePagesPerViewport = object : PageSize {    override fun Density.calculateMainAxisPageSize(        availableSpace: Int,        pageSpacing: Int    ): Int {        return (availableSpace - 2 * pageSpacing) / 3


Snap Position: Aligning Your Content

When you have multiple pages visible on the screen at once (using a custom PageSize), where the pager "snaps" becomes very important. By default, the snap position is SnapPosition.Start, meaning the "current" page will align with the beginning of the pager container.

@Composablefun HorizontalPagerView() {    Box(        modifier = Modifier            .fillMaxSize()            .background(Color.White)    ) {        val state = rememberPagerState { 50 }        HorizontalPager(            modifier = Modifier.fillMaxSize(),            state = state,            pageSize = threePagesPerViewport,            snapPosition = SnapPosition.Start // Default behavior        ) { page ->            PagerItem(                page = page,                modifier = Modifier                    .fillMaxWidth()                    .height(200.dp)                    .then(                        if (state.currentPage == page) {                            Modifier.border(4.dp, Color.Black)                        } else {                            Modifier


As you can see, the snap is placed to the start of the viewport. However, Compose gives you the flexibility to change this alignment to suit your design. You can center the active page using SnapPosition.Center or align it to the end using SnapPosition.End.







Content Padding and Page Peeking

Another vital parameter is contentPadding. As defined in the documentation, it adds padding around the whole content after it has been clipped. This is distinct from using a standard Modifier.padding() on the Pager itself, as it allows you to add spacing specifically at the start or end of the scrollable content.



Get Oğuzhan Aslan’s stories in your inbox


Join Medium for free to get updates from this writer.





Subscribe



One of the most common use cases for contentPadding is to allow the next (and previous) pages to "peek" onto the screen. This signals to the user that there is more content to explore.

@Composablefun HorizontalPagerWithPeeking() {    Box(        modifier = Modifier            .fillMaxSize()            .background(Color.White)    ) {        val state = rememberPagerState { 5 }                HorizontalPager(            modifier = Modifier.fillMaxSize(),            state = state,            // Adds 16.dp padding to the start and end of the content            contentPadding = PaddingValues(horizontal = 16.dp),        ) { page ->            PagerItem(                page = page,                modifier = Modifier                    .fillMaxWidth()                    .height(200.dp

When you apply a horizontal padding like 16.dp, the Pager reduces the width available for the "current" page just enough to let a sliver of the neighboring pages show at the edges. Note that this padding applies to all pages; if you use a vertical pager, you would apply vertical padding to achieve a similar "peeking" effect from the top or bottom.


Page Spacing

While contentPadding affects the outer edges of the pager content, pageSpacing is used to define the gap between individual pages. This is purely for the internal gutter between items and does not affect the outer bounds of the first or last page.

@Composablefun HorizontalPagerView() {    Box(        modifier = Modifier            .fillMaxSize()            .background(Color.White)    ) {        val state = rememberPagerState { 5 }        HorizontalPager(            modifier = Modifier.fillMaxSize(),            state = state,            pageSpacing = 16.dp,            pageSize = threePagesPerViewport,        ) { page ->            PagerItem(                page = page,                modifier = Modifier                    .fillMaxWidth()                    .height(200.dp


Interestingly, you can even use negative values for pageSpacing. If you set something like pageSpacing = (-16).dp, the pages will actually overlap each other. This can be used for creative stack-like animations or stylized card layouts.


Lazy Loading and Performance

Pages in both HorizontalPager and VerticalPager are lazily composed and laid out only when required. This is a core performance feature: as the user scrolls, the pager removes any pages that are no longer needed, keeping memory usage low.

By default, the pager only loads the pages that are currently visible on the screen. However, you can use the beyondViewportPageCount parameter to load pages off-screen ahead of time. This can make swiping feel smoother, as the next page is already composed and measured before it enters the viewport.

Managing Beyond-Viewport Pages

While it’s tempting to set a high count to ensure everything is “ready,” readers should be careful. Every page loaded beyond the viewport is still being composed, measured, and placed. Setting this count too high can lead to performance degradation, especially with complex page layouts.

Pagers even support dynamic changes for the beyond page count, allowing you to adjust it based on app state or user settings:

@Composablefun HorizontalPagerBeyondBounds() {    Box(        modifier = Modifier            .fillMaxSize()            .background(Color.White)    ) {        var beyondPageCount by remember { mutableIntStateOf(1) }        val state = rememberPagerState(pageCount = { 50 })                HorizontalPager(            modifier = Modifier.fillMaxSize(),            state = state,            beyondViewportPageCount = beyondPageCount        ) { page ->            PagerItem(                page = page,                modifier = Modifier.fillMaxSize()            )        }// Control UI to demonstrate dynamic changes        FlowRow(            modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp)        ) {            repeat(3) { index ->                val count = index + 1                Button(onClick = { beyondPageCount = count }) {                    Text("Beyond Page Count: $count"



Dynamic Page Counts

Because the pageCount in PagerState is defined as a lambda, Pagers have no problem handling dynamic changes to the total number of pages. You can add or remove items from your data source, and the Pager will reactively update its scroll range.

@Composablefun DynamicPagerView() {    var pages by remember { mutableStateOf<List<String>>(emptyList()) }    val state = rememberPagerState(pageCount = { pages.size })    Box(modifier = Modifier.fillMaxSize()) {            HorizontalPager(state = state) { page ->                PagerItem(page = page)            }            Button(                modifier = Modifier.align(Alignment.BottomCenter),                onClick = {                    pages = pages + "New Page: ${pages.size + 1}"                }            ) {                Text("Add Page"


Working with Paging 3

By extension, this dynamic capability makes the Pager component a perfect match for the Paging 3 library. When dealing with massive datasets from a network or database, Paging 3 handles the chunking and loading, while the Pager provides the UI.

First, include the dependency:

// libs.versions.tomlpaging = "3.3.6"androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging"

You can then define a PagingSource and collect those items within your Composable:

@Composablefun Paging3PagerView(viewModel: HorizontalPagerViewModel = viewModel()) {    val items = viewModel.items.collectAsLazyPagingItems()    val state = rememberPagerState(pageCount = { items.itemCount })    HorizontalPager(            modifier = Modifier.fillMaxSize(),            state = state,        ) { page ->            items[page]?.let { item ->                PagerItem(page = item


This setup ensures that you are only fetching data for the pages the user is actually browsing, combining the efficiency of Paging 3 with the smooth interactive experience of Compose Pagers.

Visual Transformations and Animations

One of the most powerful features of PagerState is its ability to provide real-time scroll information. Properties like currentPageOffsetFraction allow you to listen to exactly how far a page has moved from its snapped position.

By leveraging these updates, you can apply stunning visual transformations as the user swipes.

Basic Scaling Animation

A simple but effective technique is to scale the height of a page based on its distance from the center.

@Composablefun ScalingPagerView() {    val state = rememberPagerState { 10 }    HorizontalPager(        modifier = Modifier.fillMaxSize(),        state = state,        pageSpacing = 16.dp,        snapPosition = SnapPosition.Center,        pageSize = threePagesPerViewport,    ) { page ->        // Calculate absolute distance from current snap position        val pageOffset = state.getOffsetDistanceInPages(page).absoluteValue                PagerItem(            page = page,            modifier = Modifier                .fillMaxWidth()                .height(200.dp * max(1 - pageOffset, 0.5f)) // Scales between 50% and 100


Advanced 3D Radial Transformation

For a more “premium” feel, you can use the graphicsLayer modifier to manipulate rotation, alpha, and scale simultaneously. This creates a radial, carousel-like effect.

@Composablefun RadialPagerView() {    val state = rememberPagerState { 10 }    val density = LocalDensity.current    HorizontalPager(            modifier = Modifier.fillMaxSize(),            state = state,            pageSpacing = 16.dp,            snapPosition = SnapPosition.Center,            pageSize = threePagesPerViewport,        ) { page ->            PagerItem(                page = page,                modifier = Modifier                    .fillMaxWidth()                    .graphicsLayer {                        // Calculate signed offset including fractional movement                        val pageOffset = ((state.currentPage - page) + state.currentPageOffsetFraction)                        // Animate Alpha (30% to 100%)                        alpha = lerp(                            start = 0.3f,                            stop = 1f,                            fraction = 1f - pageOffset.absoluteValue.coerceIn(0f, 1f)                        )                        // Animate Scale (80% to 100%)                        val scale = lerp(                            start = 0.8f,                            stop = 1f,                            fraction = 1f - pageOffset.absoluteValue.coerceIn(0f, 1f)                        )                        scaleX = scale                        scaleY = scale                        // 3D rotation and perspective                        rotationY = pageOffset * -30f                        cameraDistance = 12 * density.density


Customizing Fling Behavior

Another critical aspect of the Pager experience is how it feels when a user “flings” or swipes quickly across the screen. By default, the flingBehavior of a Pager is configured with a PagerSnapDistance of 1. This means that no matter how hard you swipe, the pager will only move one page at a time.

However, for gallery views or fast-scrolling lists, you might want to allow users to skip multiple pages in a single gesture. You can customize this by defining a custom flingBehavior:

val fling = PagerDefaults.flingBehavior(    state = state,    pagerSnapDistance = PagerSnapDistance.atMost(3))HorizontalPager(    state = state,    flingBehavior = fling,    // ... other parameters) { page ->    PagerItem(page = page

By setting PagerSnapDistance.atMost(3), the user can now snap up to three pages at a time with a single swipe, making navigation through long lists much faster and more engaging.


Summary

The transition from the legacy ViewPager to the Compose Pager represents a significant leap in developer productivity and UI flexibility. By embracing a state-driven model, we move away from complex adapter hierarchies and toward a declarative system where animations and data updates happen naturally.

Throughout this guide, we’ve seen how PagerState acts as the brain of your component—managing everything from simple scroll positions to complex 3D transformations. Whether you are building a simple onboarding flow with dynamic page counts, integrating large datasets with Paging 3, or crafting high-end bespoke animations using graphicsLayer, the Compose Pager provides a robust and scalable foundation. As you implement these patterns in your own projects, remember that the true power of Compose lies in its composability: these pagers can be nested, combined, and styled to fit almost any mobile experience you can imagine.


LinkedIn

Love you all.

Stay tune for upcoming blogs.

Take care.