Parallax Scroll Effect Implementation in Mobile Apps
A parallax scroll effect occurs when background images move slower than content, creating an illusion of depth. This technique is commonly applied to product cards, hero sections, and user profiles. The difference between poor and excellent implementation isn't in the visual concept but rather in whether the animation runs on the main thread or not.
iOS: Correct Parallax Without Main Thread Blocking
A naive implementation using UIScrollViewDelegate.scrollViewDidScroll with frame or transform updates works but appears stuttery. Each frame triggers a delegate call on the main thread, calculates offset, and updates layout. On iPhone SE 2nd gen with fast scrolling, this can drop to 45 FPS.
The correct UIKit approach uses CAScrollLayer or separate CALayer transforms. The most elegant solution is UICollectionViewCompositionalLayout with orthogonalScrollingBehavior and supplementaryContentInsetsReference. For simple parallax in cells:
override func layoutSubviews() {
super.layoutSubviews()
// Called on bounds changes, including collection view scroll
guard let superview = superview else { return }
let cellFrameInSuperview = convert(bounds, to: superview)
let parallaxOffset = cellFrameInSuperview.minY * 0.3
heroImageView.transform = CGAffineTransform(translationX: 0, y: -parallaxOffset)
}
layoutSubviews executes during every layout pass, including scroll—this runs on main thread but without unnecessary delegates or DispatchQueue calls. The coefficient 0.3 means 30% of the scroll offset. The image should extend above the cell by approximately cellHeight * parallaxRatio on each side.
In SwiftUI, parallax is achieved through ScrollView and GeometryReader:
ScrollView {
LazyVStack {
ForEach(items) { item in
GeometryReader { geo in
let offset = geo.frame(in: .global).minY
Image(item.imageName)
.resizable()
.scaledToFill()
.frame(height: 250)
.offset(y: offset * 0.3)
.clipped()
}
.frame(height: 200) // visible area smaller than image
}
}
}
GeometryReader reads position in global coordinates—this recalculates with each scroll. On iOS 17+, use .scrollTargetBehavior and ScrollView with onScrollGeometryChange for better performance.
Android: Parallax Without Jank
A naive iOS implementation using RecyclerView.OnScrollListener with view.translationY = offset * factor causes similar issues: scroll listener on main thread and unnecessary measure/layout passes.
The best approach is MotionLayout with scroll-triggered OnSwipe through NestedScrollView:
<MotionScene>
<Transition motion:constraintSetStart="@id/start" motion:constraintSetEnd="@id/end"
motion:duration="1000">
<OnSwipe
motion:touchAnchorId="@id/nestedScrollView"
motion:touchAnchorSide="top"
motion:dragDirection="dragUp"
motion:moveWhenScrollAtTop="true" />
</Transition>
<ConstraintSet android:id="@+id/start">
<Constraint android:id="@+id/heroImage"
android:translationY="0dp" ... />
</ConstraintSet>
<ConstraintSet android:id="@+id/end">
<Constraint android:id="@+id/heroImage"
android:translationY="-60dp" ... />
</ConstraintSet>
</MotionScene>
MotionLayout manages animation based on scroll progress—the image shifts as content scrolls without main thread callbacks.
For RecyclerView with per-item parallax, use RecyclerView.ItemDecoration with onDrawOver:
class ParallaxDecoration(private val factor: Float = 0.3f) : RecyclerView.ItemDecoration() {
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
for (i in 0 until parent.childCount) {
val child = parent.getChildAt(i)
val imageView = child.findViewById<ImageView>(R.id.heroImage) ?: continue
val centerOffset = (parent.height / 2f) - (child.top + child.height / 2f)
imageView.translationY = centerOffset * factor
}
}
}
onDrawOver executes during each draw pass—this is performant because it's tied to rendering rather than scroll events.
Jetpack Compose
@Composable
fun ParallaxCard(item: Item) {
val density = LocalDensity.current
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(12.dp))
) {
var offsetY by remember { mutableStateOf(0f) }
Box(modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
// Called on positioning—use carefully
}
)
// Use ScrollState through LazyListState
// Implementation through rememberLazyListState() + derivedStateOf
}
}
Correct parallax in Compose uses LazyListState and derivedStateOf:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
itemsIndexed(items) { index, item ->
val itemOffset by remember {
derivedStateOf {
val itemInfo = listState.layoutInfo.visibleItemsInfo.find { it.index == index }
itemInfo?.let { (listState.layoutInfo.viewportEndOffset / 2f) - (it.offset + it.size / 2f) } ?: 0f
}
}
Box(modifier = Modifier.height(200.dp).fillMaxWidth()) {
Image(
painter = painterResource(item.imageRes),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer { translationY = itemOffset * 0.3f },
contentScale = ContentScale.Crop
)
}
}
}
graphicsLayer applies transformation on GPU without layout invalidation—the most performant way in Compose.
Timeline
Parallax for a single-screen hero image: half a day. Parallax in a list with many elements (RecyclerView / LazyColumn / LazyVStack): 1 day. Cost is calculated individually.







