Centered Hero Carousel
A horizontal carousel where the focused card is a big rectangle and the cards beside it are small rectangles that continuously morph as you scroll. At rest, the center card is large, the neighbors peek out small on both sides, and a swipe advances one card with a spring snap.
This is the effect used by the "Quick Picks" section on music-app home screens.
IMPLEMENTATION INVARIANT
Do not implement the morph by changing the measured width of the pager page.
The page's layout size stays constant. The visual effect is produced from the scroll position by:
- determining the page center,
- resolving it against keylines (the keyline before and the keyline after),
- interpolating both the keyline's size and its offset,
- clipping the page to the interpolated width,
- translating the page to the interpolated offset,
- applying z-order (focused page on top).
Never use
Modifier.width()driven by scroll,animateDpAsState,animateContentSize, or per-frame re-measurement. Those produce re-layout jank and are not this effect.
How the effect actually works
The cards do not change size in layout, and this is not a LazyRow. It is a pager
of full-size pages where each page gets a clip rectangle (its visible width) plus a
translation that pins its clipped edges against its neighbors. Every scroll frame, the clip
width and translation are recomputed from the scroll offset — a smooth, fraction-of-a-pixel
interpolation, never a discrete step.
The mechanism:
- Pages are measured end-to-end at one fixed "hero" width. The focused card is that width.
- Keylines are fixed positions along the scroll axis that define the width a card should
have when its center is there. For a centered hero the scheme is
[small | LARGE | small]: the large keyline = hero width; the small keylines ≈ large/3 (clamped, e.g. 40–56dp); tiny anchor keylines at the very edges keep cards from collapsing. - Per-frame mask + translate + z-order (see the math below).
The math — keyline interpolation (framework-agnostic)
A keyline is a (offset, size) pair: the width a card should have when its center is at
offset. For a centered hero, relative to the focus position, with H = hero width, W =
small width, S = item spacing:
keylines = [ (-(H/2 + S + W/2), W), // left small
( 0, H), // LARGE
(+(H/2 + S + W/2), W) ] // right small
For each visible page, distanceFromFocus is how many page-strides it sits from the focused
page (a continuous float during scroll). Then:
relCenter = distanceFromFocus * (H + S) // page center vs. viewport center
before = keyline with the largest offset <= relCenter
after = keyline with the smallest offset >= relCenter
progress = (relCenter - before.offset) / (after.offset - before.offset)
size = lerp(before.size, after.size, progress)
keylineOff = lerp(before.offset, after.offset, progress)
clip width = size // mask rect, centered on the page
translate = keylineOff - relCenter // pin the clipped edge to its neighbour
zIndex = 1 / (1 + distanceFromFocus)
size and translate are pure functions of the scroll position — that is what makes the
morph continuous and cheap. Worked trace at rest: focused page relCenter = 0 → size H,
translate 0; neighbor at relCenter = H + S → size W; halfway between the large and small
keyline → size halfway between H and W. Because adjacent pages interpolate against the
same shared small keyline, their clipped edges stay pinned together with no gap.
Choosing a fidelity tier
| Tier | Use when | Effect fidelity | Dependency |
|---|---|---|---|
| 1. Material replication | Android/Compose, need the exact Material behavior (anchors, edge shifting, built-in snap) | Exact | androidx.compose.material3 carousel |
| 2. Custom keyline | Need the real morph but no experimental API, or custom keylines | Very close (same keyline + clip/translate mechanism) | HorizontalPager only |
| 3. Approximation (scale) | Visual similarity is enough and simplicity matters | Similar look, proportional scaling, not width-only | HorizontalPager only |
Tier 1 — Material replication (exact)
This is the reference implementation. It also handles snapping, RTL, and the end-of-list edge cases (the first/last card grows large at the edge of the screen) for you.
Dependency: androidx.compose.material3:material3 with the carousel APIs (present since
1.3.0; confirmed on 1.5.x).
Opt-in required: @OptIn(ExperimentalMaterial3Api::class).
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HeroCarousel(
itemCount: Int,
heroWidth: Dp,
carouselHeight: Dp,
modifier: Modifier = Modifier,
content: @Composable (index: Int) -> Unit,
) {
HorizontalCenteredHeroCarousel(
state = rememberCarouselState { itemCount },
maxItemWidth = heroWidth, // the LARGE keyline size
itemSpacing = 10.dp, // gap between pages
contentPadding = PaddingValues(horizontal = 16.dp),
modifier =
modifier
.fillMaxWidth()
.height(carouselHeight),
) { index ->
content(index)
}
}
Typical card content (full-bleed artwork + scrim + title — the Quick Picks look). maskClip
/ maskBorder come from CarouselItemScope and keep the corners correct while the mask morphs:
HorizontalCenteredHeroCarousel(...) { index ->
val item = items[index]
Box(
Modifier
.fillMaxSize()
.maskClip(MaterialTheme.shapes.extraLarge) // rounded corners on the mask
.maskBorder(BorderStroke(1.dp, outlineVariant), extraLarge) // optional outline
.clickable { /* play */ },
) {
AsyncImage(model = item.artwork, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
Box( // bottom scrim
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to Color.Transparent,
0.48f to Color.Black.copy(alpha = 0.08f),
1f to Color.Black.copy(alpha = 0.84f),
),
),
)
Text(
item.title,
color = Color.White,
fontWeight = FontWeight.Black,
modifier = Modifier.align(Alignment.BottomStart).padding(16.dp),
)
}
}
Tuning:
| Param | Effect |
|---|---|
maxItemWidth |
Large keyline size. Dp.Unspecified = one large item filling the viewport minus two small items. For a hero slightly narrower than the screen, use viewportWidth - 48.dp (clamped). |
itemSpacing |
Gap between pages (e.g. 10.dp). |
minSmallItemWidth / maxSmallItemWidth |
Clamp for the small side cards (defaults 40.dp / 56.dp). |
contentPadding |
Inset before the first / after the last page. |
flingBehavior |
Default singleAdvanceFlingBehavior = one card per swipe with spring(MediumLow) snap. |
Tier 2 — Custom keyline implementation (no experimental API)
A faithful port of the same mechanism: pages stay at hero width, a graphicsLayer clips each
page to the interpolated keyline width and translates it to stay pinned. Reads the pager's
per-frame offset inside graphicsLayer, so it morphs without recomposition.
@Composable
fun KeylineHeroCarousel(
itemCount: Int,
heroWidth: Dp,
carouselHeight: Dp,
modifier: Modifier = Modifier,
smallWidth: Dp = 56.dp,
itemSpacing: Dp = 10.dp,
content: @Composable (index: Int) -> Unit,
) {
val density = LocalDensity.current
val heroPx = with(density) { heroWidth.toPx() }
val smallPx = with(density) { smallWidth.toPx() }
val spacingPx = with(density) { itemSpacing.toPx() }
val pagerState = rememberPagerState { itemCount }
val mask = remember { MutableMaskRect() }
val maskShape = remember(mask) { MaskRectShape(mask) }
BoxWithConstraints(modifier = modifier) {
val viewportPx = with(density) { maxWidth.toPx() }
// The hero must fit inside the viewport, or the centering math is meaningless.
val safeHeroPx = heroPx.coerceAtMost(viewportPx)
val peekPx = ((viewportPx - safeHeroPx) / 2f).coerceAtLeast(0f)
HorizontalPager(
state = pagerState,
pageSize = remember(safeHeroPx) {
object : PageSize {
override fun Density.calculateMainAxisPageSize(
availableSpace: Int,
pageSpacing: Int,
): Int = safeHeroPx.roundToInt()
}
},
pageSpacing = itemSpacing,
contentPadding = PaddingValues(horizontal = peekPx.dp),
beyondViewportPageCount = 1,
modifier = Modifier.fillMaxWidth().height(carouselHeight),
) { index ->
val stride = safeHeroPx + spacingPx
val smallOffset = safeHeroPx / 2f + spacingPx + smallPx / 2f
// Keylines relative to the focus: [small | LARGE | small]
val keylines = listOf(
Keyline(-smallOffset, smallPx),
Keyline(0f, safeHeroPx),
Keyline(smallOffset, smallPx),
)
Box(
modifier =
Modifier
.fillMaxSize()
.zIndex(1f / (1f + abs(pagerState.currentPage - index)))
.graphicsLayer {
// Per-frame distance from the focused page, in strides.
val distance =
(pagerState.currentPage - index) +
pagerState.currentPageOffsetFraction
val relCenter = distance * stride
// Interpolate between the keylines bracketing this page's center.
val before = keylines.lastOrNull { it.offset <= relCenter }
val after = keylines.firstOrNull { it.offset >= relCenter }
val lo = before ?: after!!
val hi = after ?: before!!
val progress =
if (hi.offset > lo.offset) {
((relCenter - lo.offset) / (hi.offset - lo.offset))
.coerceIn(0f, 1f)
} else {
0f
}
val size = lerp(lo.size, hi.size, progress)
val keylineOffset = lerp(lo.offset, hi.offset, progress)
// Clip to the interpolated width (mask rect is page-local).
mask.set(
safeHeroPx / 2f - size / 2f,
0f,
safeHeroPx / 2f + size / 2f,
this.size.height,
)
this.shape = maskShape
clip = true
// Translate so the clipped center sits on the interpolated keyline.
translationX = keylineOffset - relCenter
},
) {
content(index)
}
}
}
}
private data class Keyline(val offset: Float, val size: Float)
private class MutableMaskRect {
var left = 0f
var top = 0f
var right = 0f
var bottom = 0f
fun set(l: Float, t: Float, r: Float, b: Float) {
left = l; top = t; right = r; bottom = b
}
}
private class MaskRectShape(private val mask: MutableMaskRect) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline = Outline.Rectangle(Rect(mask.left, mask.top, mask.right, mask.bottom))
}
Important details:
- The
graphicsLayerblock runs every draw frame; readingpagerState.currentPageOffsetFractionthere gives the live offset without recomposition — that is what makes the morph buttery. - The mask is a rectangle (width-only morph, height constant — the real effect). Rounded
corners are NOT produced by
clip = true; they come from the card content's own shape, so make each page's content aCard/.clip(RoundedCornerShape(...))of its own. - The
clip = truehere only clips to the computed mask shape; the width morph is the mask, the height stays full. - If your compiler flags
PageSize, add@OptIn(ExperimentalFoundationApi::class)to the composable. - Edge behavior: unlike Tier 1, the first/last card does not grow large at the screen edge — it simply stays small. Acceptable for short lists or when users rarely reach the ends.
- Snap: Pager snaps by default. To limit a fling to one card:
flingBehavior = PagerDefaults.flingBehavior(state = pagerState, pagerSnapDistance = PagerSnapDistance.atMost(1)).
Tier 3 — Approximation (proportional scaling)
Warning: this is NOT the same effect. It scales the whole card proportionally (both dimensions), so rectangles keep their proportions instead of morphing width-only. Use it only when visual similarity is enough and the clip-based approaches aren't worth it.
Rounded corners come from the Card's own shape (the Card clips itself) — graphicsLayer { clip = true } would only clip to the rectangular layer bounds, it does not create the
morphing mask.
@Composable
fun ScaledHeroCarousel(
itemCount: Int,
heroWidth: Dp,
carouselHeight: Dp,
modifier: Modifier = Modifier,
itemSpacing: Dp = 10.dp,
smallScale: Float = 0.72f, // scale of the side cards
edgeAlpha: Float = 0.65f, // fade at the far edges
content: @Composable (index: Int) -> Unit,
) {
val density = LocalDensity.current
val heroPx = with(density) { heroWidth.toPx() }
val pagerState = rememberPagerState { itemCount }
BoxWithConstraints(modifier = modifier) {
val viewportPx = with(density) { maxWidth.toPx() }
val safeHeroPx = heroPx.coerceAtMost(viewportPx) // hero must fit the viewport
val peekPx = ((viewportPx - safeHeroPx) / 2f).coerceAtLeast(0f)
HorizontalPager(
state = pagerState,
pageSize = remember(safeHeroPx) {
object : PageSize {
override fun Density.calculateMainAxisPageSize(
availableSpace: Int,
pageSpacing: Int,
): Int = safeHeroPx.roundToInt()
}
},
pageSpacing = itemSpacing,
contentPadding = PaddingValues(horizontal = peekPx.dp),
beyondViewportPageCount = 2,
modifier = Modifier.fillMaxWidth().height(carouselHeight),
) { index ->
Box(
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
val distance =
(pagerState.currentPage - index) +
pagerState.currentPageOffsetFraction
val progress = (1f - abs(distance)).coerceIn(0f, 1f) // 1.0 = focused
scaleX = lerp(smallScale, 1f, progress)
scaleY = lerp(smallScale, 1f, progress)
translationX = distance * 24f // extra "pop"
alpha = lerp(edgeAlpha, 1f, progress)
},
) {
Card(
shape = RoundedCornerShape(28.dp), // rounding lives on the Card, not the layer
modifier = Modifier.fillMaxSize(),
) {
content(index)
}
}
}
}
}
Porting to other frameworks
Same three ingredients, different vocabulary — and the keyline math above is the port.
- SwiftUI:
ScrollView(.horizontal)+onScrollGeometryChangeto read the offset, then per-viewscaleEffect/offset(x:)/zIndex— orTabView(PageStyle)for snap. For the width-only morph useclipShapewith a rectangle sized bysizefrom the keyline math. - Flutter:
PageView.builder+AnimatedBuilderreading the fractional page offset, each page wrapped inTransform.scale/Transform.translate;viewportFractioncontrols the peeks. - Web/JS: a scroll container reading
scrollLeft;transform: scale()+translateX()+z-index, orclip-path: inset()for the width-only variant.scroll-snap-type: x mandatoryfor snapping.
Whichever stack you use, the invariant holds: pages are full-size; you only ever change a clip and a translate (plus z-order) as a pure function of scroll offset.
Customization & gotchas
- Gap / small-size clamping: keep the small keyline at roughly large/3 but clamped (40–56dp); otherwise side cards on a wide hero look comically tiny or too big to read as "peek".
- Height must be fixed: all pages share one row height (e.g. 332–380dp for phone/tablet); the
morph changes only width. Give the carousel an explicit
height(...). - Round corners on the masked shape (Tier 1:
maskClip/maskBorder; Tier 2: aCard/clip inside the page), never on the unscaled page — otherwise the full-size page's corners slide around while it morphs. - Hero must fit the viewport (Tier 2/3): clamp
heroWidth <= maxWidthand derive the peeks fromBoxWithConstraints, or the centering math is nonsense. - Edge behavior: Tier 1 keeps the first/last card growing large at the screen edge via internal keyline "steps". Tiers 2–3 just show edge cards small.
- Prefetch: set
beyondViewportPageCount(Tier 1 handles this internally) so neighbors are measured before they scroll into the morph region. - Accessibility: keep each card a real tappable element with a label; the clip is purely visual and must not remove focus targets.
Anti-patterns (do NOT do these)
- ❌
Modifier.width(...)animated from scroll position — re-layout churn, jank. - ❌
animateDpAsState/animateContentSizedriven by the current page — discrete, not the per-frame morph, and it fights the scroll. - ❌ Re-measuring pages at different sizes per index in the pager — breaks the invariant.
- ❌ Building it on a
LazyRowwith per-item width snapshots — you lose the pinned-edge continuity and the buttery per-frame morph.