HorizontalPager is a lazy list that snaps — and PagerState is where the bugs live
HorizontalPager brings paging into Compose with no external dependency. The interesting surface is PagerState — currentPage versus settledPage, scroll versus animateScrollToPage, and how to sync tabs without a loop.

Day 25 of 100. Onboarding carousels, image galleries, tabbed screens. HorizontalPager
has been in the foundation library since Compose 1.4 — no Accompanist, no ViewPager2
interop.
The symptom
A pager wired to a tab row, where selecting a tab fights the swipe:
var selectedTab by remember { mutableStateOf(0) }
val pagerState = rememberPagerState { pages.size }
TabRow(selectedTabIndex = selectedTab) {
pages.forEachIndexed { i, p ->
Tab(selected = selectedTab == i, onClick = { selectedTab = i }) { Text(p.title) }
}
}
HorizontalPager(state = pagerState) { page -> PageContent(pages[page]) }
LaunchedEffect(selectedTab) { pagerState.animateScrollToPage(selectedTab) }
LaunchedEffect(pagerState.currentPage) { selectedTab = pagerState.currentPage }
Swipe halfway and release: the tab flickers, sometimes lands on the wrong page, and occasionally the pager animates back and forth before settling.
Two sources of truth, each driving the other. Day 11's bug, in an animated container that makes it visible.
Why the obvious fix fails
The obvious fix is a guard flag:
var updatingFromPager by remember { mutableStateOf(false) }
LaunchedEffect(selectedTab) {
if (!updatingFromPager) pagerState.animateScrollToPage(selectedTab)
}
This is the flag from Day 9 wearing different clothes. It works until an animation is interrupted mid-flight, at which point the flag is stuck in the wrong state and the tabs stop responding entirely — a bug that reproduces roughly once per twenty attempts and takes an afternoon to pin down.
The real fix is to delete one of the two sources of truth.

The actual mechanism
PagerState is the selected-page state. There is no second variable:
val pagerState = rememberPagerState(initialPage = 0) { pages.size }
val scope = rememberCoroutineScope()
TabRow(selectedTabIndex = pagerState.currentPage) {
pages.forEachIndexed { i, p ->
Tab(
selected = pagerState.currentPage == i,
onClick = { scope.launch { pagerState.animateScrollToPage(i) } },
) { Text(p.title) }
}
}
HorizontalPager(state = pagerState) { page -> PageContent(pages[page]) }
No LaunchedEffect, no flag, no loop. The tab row reads currentPage; tapping a tab
writes by scrolling. One owner, and Day 11's rule applied to a container that
otherwise makes the violation very visible.
Note the page count is a lambda — rememberPagerState { pages.size }. It's read
lazily so the pager survives the list changing size without being recreated.
currentPage versus settledPage
Two properties, and picking the wrong one is the most common pager bug after the loop.
currentPage— the page closest to the snap position. Updates during a swipe, as soon as you pass the halfway point.settledPage— updates only once scrolling has fully stopped.
For a tab indicator you want currentPage, so the highlight moves with the finger. For
anything with a side effect — analytics, prefetching, starting a video — you want
settledPage, or you fire an event for every page brushed past during a fast fling:
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }
.collect { page -> analytics.screenView(pages[page].name) }
}
snapshotFlow is the right tool here rather than LaunchedEffect(pagerState.currentPage)
— it turns a state read into a flow, so you get distinct-until-changed semantics and a
single collector, rather than an effect that restarts on each change.
For a smooth indicator you can go finer still: currentPageOffsetFraction is the
fractional scroll position between −0.5 and 0.5, which is what drives a dot indicator
that slides rather than jumps.
The parameters worth knowing
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fill, // or Fixed(200.dp) for a peeking carousel
contentPadding = PaddingValues(horizontal = 32.dp), // shows neighbours
pageSpacing = 16.dp,
beyondViewportPageCount = 1, // compose N pages either side
) { page -> … }
contentPadding plus pageSize is how you get the "card carousel with the next card
peeking" layout without any custom measurement.
beyondViewportPageCount defaults to 0 — only the visible page is composed. Raising it
to 1 makes swipes feel smoother for expensive pages, at the cost of composing them
early. It is not free, and setting it high to fix jank usually means the page content is
too expensive rather than that the pager needs more buffer.
Infinite paging
There's no built-in infinite mode; the idiom is a large virtual count with modulo indexing:
val pageCount = Int.MAX_VALUE
val state = rememberPagerState(initialPage = pageCount / 2) { pageCount }
HorizontalPager(state = state) { page ->
PageContent(items[page % items.size])
}
Start in the middle so there's room to swipe both ways. It's a trick rather than a
feature, and worth a comment in the code — the Int.MAX_VALUE reads as a mistake
otherwise.
Two details make the difference between this working and feeling broken. Start at
pageCount / 2 rather than 0, or the user hits a wall swiping backwards within a few
gestures. And index with page % items.size rather than tracking an offset yourself —
the modulo is what keeps content correct after thousands of pages without any
bookkeeping.
How to prove it
The single-source-of-truth property is testable without a device:
@Test fun tabFollowsPager() = runComposeUiTest {
setContent { TabbedPager(pages) }
onNodeWithTag("pager").performTouchInput { swipeLeft() }
onNodeWithTag("tab-1").assertIsSelected()
}
If the test needs a waitForIdle plus a sleep to pass reliably, you probably still have
two sources of truth settling against each other.
For the currentPage/settledPage distinction, log both during a fast fling across ten
pages. currentPage will step through most of them; settledPage should fire once. If
your analytics uses the first, that difference is a lot of spurious events.
The loop bug has an equally direct check. Log every animateScrollToPage call together
with its caller, then swipe once. A single deliberate swipe should produce zero
programmatic scrolls — if it produces one, an effect is reacting to the page change by
scrolling again, and that is the feedback loop regardless of whether it happens to
settle correctly today.
What this generalizes to
The lesson is one already met twice: a component that owns state should be the only
one that owns it. PagerState is a Day 17 state holder — a plain class, created with
remember, scoped to the composition — and the bug at the top of this post came from
duplicating it rather than reading it.
Any time you find yourself writing two effects that update each other, the fix is almost never a guard flag. It's noticing that one of the two variables shouldn't exist.
A note on vertical pagers and nesting
VerticalPager is the same API on the other axis, and it's what a full-screen video
feed is built from. The nesting rule from Day 23 applies with a twist: a VerticalPager
containing a LazyColumn works, because the pager consumes the fling and the inner list
scrolls within a settled page — but only once the inner list has bounded height, which
it gets from the pager filling the viewport.
The combination that misbehaves is two scrollables on the same axis with no snapping boundary between them. If a vertical pager page needs vertical scrolling, let the page own it and keep the pager's own gesture for the page transition; don't add a third scrollable in between hoping the gestures sort themselves out.
Tomorrow, Day 26: FlowRow and FlowColumn — wrapping layouts, and the chip group
that finally doesn't need a custom Layout.
Day 25 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Pager in Compose.