
If you have ever built an onboarding screen, an image carousel, or a tabbed news feed in Android, you have likely dealt with the complexity of the old ViewPager from the View system. Jetpack Compose Pager replaces all of that with a composable that fits naturally into the Compose mental model — declarative, state-driven, and surprisingly simple. Whether you are building a multi-step form, a story feed, or a photo gallery, the Compose Pager API gives you everything you need with clean and readable code.
In this tutorial you will learn how HorizontalPager, VerticalPager, and rememberPagerState work together. You will build animated dot indicators, sync a pager with a tab row, and end with a complete onboarding screen that ties it all together.
The Jetpack Compose Pager lives inside the foundation library. Until Compose Foundation 1.4.0 the pager was part of the third-party Accompanist library, but it has since been promoted to stable in the official androidx.compose.foundation.pager package. You no longer need any extra dependency beyond what a standard Compose project already uses.
Add these to your module-level build.gradle.kts file:
dependencies {
implementation("androidx.compose.foundation:foundation:1.6.7")
implementation("androidx.compose.material3:material3:1.2.1")
implementation("androidx.activity:activity-compose:1.9.0")
}
Sync your project and you are ready to start building swipeable screens with the Jetpack Compose Pager.
Before you place a pager on screen you need to understand rememberPagerState — the state holder that drives the entire pager. It stores which page is currently visible, how far through a swipe the user is, and how many pages exist in total.
You create it by passing a lambda that returns the page count. Using a lambda rather than a raw integer means the count can be dynamic, which is useful when pages are loaded from a network request or a database.
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun PagerStateInfoExample() {
val pageCount = 5
val pagerState = rememberPagerState(pageCount = { pageCount })
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(text = "Current page: ${pagerState.currentPage}")
Text(text = "Total pages: ${pagerState.pageCount}")
Text(text = "Is scrolling: ${pagerState.isScrollInProgress}")
}
}
When this composable renders you will see three lines of text. The current page is zero-indexed so the first page shows as 0. The currentPageOffsetFraction property — available on the same state object — tells you how far between two pages the user currently is, which you can use to drive animations as the user swipes.
HorizontalPager is the most common form of the Jetpack Compose Pager. It lets users swipe left and right between pages, which makes it ideal for onboarding flows, image galleries, and multi-step wizards.
You pass it a state created by rememberPagerState and a composable lambda that receives the current page index. Inside that lambda you use the page index to decide what content to display.
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
@Composable
fun SimpleHorizontalPagerExample() {
val colors = listOf(Color(0xFFFF6B6B), Color(0xFF4ECDC4), Color(0xFF45B7D1))
val labels = listOf("Welcome", "Discover", "Get Started")
val pagerState = rememberPagerState(pageCount = { colors.size })
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { pageIndex ->
Box(
modifier = Modifier
.fillMaxSize()
.background(colors[pageIndex]),
contentAlignment = Alignment.Center
) {
Text(
text = labels[pageIndex],
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
}
When you run this, the screen fills with a coral red background showing "Welcome" centered in white bold text. Swiping left transitions smoothly to the teal "Discover" page and then to the sky blue "Get Started" page. The swipe gesture is handled entirely by Compose — no touch listeners, no adapters, no fragment transactions.
VerticalPager works exactly like HorizontalPager but the swipe direction is vertical. This is perfect for short-form video feeds, news card stacks, or any story-style layout where users scroll upward through content.
Switching from horizontal to vertical swiping requires nothing more than changing the composable name. The state, the page lambda, and all the properties behave identically.
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
@Composable
fun StoryFeedPagerExample() {
val stories = listOf(
Pair("Breaking News", Color(0xFF2C3E50)),
Pair("Tech Update", Color(0xFF8E44AD)),
Pair("Sports Recap", Color(0xFF27AE60)),
Pair("Weather Report", Color(0xFF2980B9))
)
val pagerState = rememberPagerState(pageCount = { stories.size })
VerticalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { pageIndex ->
val (title, bgColor) = stories[pageIndex]
Box(
modifier = Modifier
.fillMaxSize()
.background(bgColor),
contentAlignment = Alignment.Center
) {
Text(
text = title,
fontSize = 28.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
}
}
The output is a full-screen dark navy card showing "Breaking News". Swiping upward moves you to the purple "Tech Update" card, then to green "Sports Recap", then to blue "Weather Report". Each page fills the screen completely and the transition looks exactly like a modern story feed, with zero extra configuration needed.
The Jetpack Compose Pager does not ship with a built-in page indicator, but building one yourself is straightforward. The core idea is simple: loop over the total page count and for each dot check whether it matches pagerState.currentPage to decide if it should appear selected or unselected.
This gives users a clear visual signal of how many pages exist and which one they are currently viewing.
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PagerWithDotIndicator() {
val pages = listOf("Step 1: Setup", "Step 2: Build", "Step 3: Deploy")
val pagerState = rememberPagerState(pageCount = { pages.size })
Column(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) { pageIndex ->
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF0F4FF)),
contentAlignment = Alignment.Center
) {
Text(
text = pages[pageIndex],
fontSize = 24.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFF1A237E)
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.Center
) {
repeat(pages.size) { dotIndex ->
val isSelected = pagerState.currentPage == dotIndex
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(if (isSelected) 12.dp else 8.dp)
.clip(CircleShape)
.background(
if (isSelected) Color(0xFF3F51B5) else Color(0xFFBBDEFB)
)
)
}
}
}
}
When you swipe through the pages the dot row at the bottom updates automatically. The active page shows a larger, dark indigo dot while the inactive dots are smaller and light blue. As soon as the page settles the correct dot enlarges and darkens — all driven reactively from the pager state without any manual synchronization.
One of the most recognizable patterns in Android apps — seen in Google Play, YouTube, and Gmail — is a tab bar that stays in sync with a swipeable pager. When the user taps a tab the pager scrolls to that page. When the user swipes the pager the active tab updates. Achieving this two-way sync with Jetpack Compose Pager requires only two things:
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
@Composable
fun TabRowWithPagerExample() {
val tabs = listOf("Android", "iOS", "Web")
val pagerState = rememberPagerState(pageCount = { tabs.size })
val coroutineScope = rememberCoroutineScope()
val pageColors = listOf(
Color(0xFF4CAF50),
Color(0xFF2196F3),
Color(0xFFFF9800)
)
Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = pagerState.currentPage) {
tabs.forEachIndexed { index, title ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = { Text(text = title) }
)
}
}
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { pageIndex ->
Box(
modifier = Modifier
.fillMaxSize()
.background(pageColors[pageIndex]),
contentAlignment = Alignment.Center
) {
Text(
text = "${tabs[pageIndex]} Content",
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
}
}
When you tap "iOS" in the tab bar, the pager smoothly animates to the blue iOS page and the tab indicator slides to highlight "iOS". When you then swipe right back to "Android", the tab indicator moves back on its own without any extra code. The coroutineScope.launch wrapper is required because animateScrollToPage is a suspend function and must execute inside a coroutine.
The official Jetpack Compose Pager documentation covers the full API including beyondBoundsPageCount for pre-loading adjacent pages, custom flingBehavior for tuning swipe velocity, and the PageSize API for showing partial pages at the edges. The PagerState API reference is also worth bookmarking when you need to programmatically scroll, read offset fractions, or track scroll positions.
The following is a complete, self-contained onboarding screen that combines a HorizontalPager, animated dot indicators, and a "Next / Get Started" button — all wired together and ready to drop into any project.
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
data class OnboardingPage(
val title: String,
val description: String,
val backgroundColor: Color,
val accentColor: Color
)
@Composable
fun OnboardingPagerScreen() {
val pages = listOf(
OnboardingPage(
title = "Track Your Goals",
description = "Set daily, weekly, and monthly goals and watch your progress grow over time.",
backgroundColor = Color(0xFFF3E5F5),
accentColor = Color(0xFF7B1FA2)
),
OnboardingPage(
title = "Stay Consistent",
description = "Build habits that stick. Daily reminders keep you on track every single day.",
backgroundColor = Color(0xFFE3F2FD),
accentColor = Color(0xFF1565C0)
),
OnboardingPage(
title = "Celebrate Wins",
description = "Every milestone counts. Earn badges and share your achievements with friends.",
backgroundColor = Color(0xFFE8F5E9),
accentColor = Color(0xFF2E7D32)
)
)
val pagerState = rememberPagerState(pageCount = { pages.size })
val coroutineScope = rememberCoroutineScope()
val isLastPage = pagerState.currentPage == pages.lastIndex
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) { pageIndex ->
val page = pages[pageIndex]
Column(
modifier = Modifier
.fillMaxSize()
.background(page.backgroundColor)
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(120.dp)
.clip(CircleShape)
.background(page.accentColor.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
) {
Text(
text = "${pageIndex + 1}",
fontSize = 48.sp,
fontWeight = FontWeight.ExtraBold,
color = page.accentColor
)
}
Spacer(modifier = Modifier.height(32.dp))
Text(
text = page.title,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = page.accentColor,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = page.description,
fontSize = 16.sp,
color = Color(0xFF455A64),
textAlign = TextAlign.Center,
lineHeight = 24.sp
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.Center
) {
repeat(pages.size) { dotIndex ->
val isSelected = pagerState.currentPage == dotIndex
val dotSize by animateDpAsState(
targetValue = if (isSelected) 14.dp else 8.dp,
label = "dotSize"
)
val dotColor by animateColorAsState(
targetValue = if (isSelected) pages[pagerState.currentPage].accentColor
else Color(0xFFCFD8DC),
label = "dotColor"
)
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(dotSize)
.clip(CircleShape)
.background(dotColor)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
coroutineScope.launch {
if (!isLastPage) {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
// else: navigate to your main screen
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 16.dp)
.height(52.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = pages[pagerState.currentPage].accentColor
)
) {
Text(
text = if (isLastPage) "Get Started" else "Next",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}