Last updated Sep 26, 2026

Building a Professional Horizontal Scrollable Table with Sticky Columns & Animations in Jetpack Compose

Building a Professional Horizontal Scrollable Table with Sticky Columns & Animations

A beginner-friendly guide to mastering dual-axis scrolling, synchronized headers, and reactive micro-interactions on Android.

When displaying tabular data on mobile screens (such as student rosters, financial reports, or schedules), limited screen width is almost always the bottleneck.

The Optimal Solution: A dual-axis table featuring vertical scrolling for infinite rows, horizontal scrolling for extra columns, and a frozen (sticky) first column so users never lose track of item identities.

1. The Core Architecture: Synchronized Dual Scrolling

A common beginner mistake is giving each row its own independent horizontal scroll state. This causes rows to misalign when scrolled.

The solution is simple: Instantiate one single ScrollState and share it across the header and all rows.

// 1. Hoist a single shared horizontal scroll state in the parent composable
val sharedHorizontalScrollState = rememberScrollState()


Column {
   // 2. Pass it into the Header Row
   TableHeaderRow(horizontalScrollState = sharedHorizontalScrollState)


   // 3. Pass it to every row in LazyColumn
   LazyColumn {
       items(studentList) { student ->
           TableRow(
               student = student,
               horizontalScrollState = sharedHorizontalScrollState
           )
       }
   }
}

2. Implementing the "Sticky / Frozen" First Column

To lock the primary column in place while allowing other attributes to scroll smoothly, place the frozen item outside the Modifier.horizontalScroll() modifier inside the same parent Row:

Row(modifier = Modifier.fillMaxWidth().height(56.dp)) {
   // --- PART A: FROZEN / STICKY COLUMN (Fixed width, doesn't scroll) ---
   Box(
       modifier = Modifier
           .width(136.dp)
           .fillMaxHeight()
           .background(Color(0xFF1E293B))
   ) {
       Text("Student Name", color = Color.White)
   }


   // --- PART B: SCROLLABLE ATTRIBUTES (Takes remaining space and scrolls) ---
   Row(
       modifier = Modifier
           .weight(1f)
           .fillMaxHeight()
           .horizontalScroll(sharedHorizontalScrollState) // <-- Shared state hook
   ) {
       Cell(text = "Roll No", width = 120.dp)
       Cell(text = "Attendance", width = 120.dp)
       Cell(text = "Start Time", width = 120.dp)
       Cell(text = "End Time", width = 120.dp)
       Cell(text = "Grade", width = 90.dp)
       Cell(text = "Score", width = 100.dp)
   }
}

3. Adding Attractive Animations & Micro-Interactions

Micro-interactions make your Android UI feel responsive, fluid, and delightful.

A. Animated Sort Arrow (Spring Physics)

When sorting the table, animate the icon's rotation using spring dynamics:

val arrowRotation by animateFloatAsState(
   targetValue = if (sortAscending) 0f else 180f,
   animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
)


Canvas(modifier = Modifier.size(14.dp).rotate(arrowRotation)) {
   // Draw arrow path
}

B. Interactive Attendance Toggle with Color Transitions

Tapping the attendance status smoothly transitions both background and text color between green (Present) and red (Absent):

val targetBg = if (isPresent) EmeraldSuccessBg else RoseDangerBg
val targetColor = if (isPresent) EmeraldSuccess else RoseDanger


val animatedBg by animateColorAsState(targetBg, tween(250))
val animatedColor by animateColorAsState(targetColor, tween(250))


Box(
   modifier = Modifier
       .clip(RoundedCornerShape(8.dp))
       .background(animatedBg)
       .clickable { onToggle() }
       .padding(horizontal = 10.dp, vertical = 4.dp)
) {
   Text(
       text = if (isPresent) "Present" else "Absent",
       color = animatedColor,
       fontWeight = FontWeight.SemiBold
   )
}

C. Tactile Touch Press Scale Effect

Compress the row slightly (0.985x) on touch down using MutableInteractionSource:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()


val scale by animateFloatAsState(
   targetValue = if (isPressed) 0.985f else 1f,
   animationSpec = spring(stiffness = Spring.StiffnessMedium)
)


Row(
   modifier = Modifier
       .scale(scale)
       .clickable(interactionSource = interactionSource, indication = null) { onRowClick() }
) { ... }

4. Summary: Best Practices for Beginners

Pattern Compose Best Practice
Synchronized Columns Pass one shared ScrollState into Modifier.horizontalScroll() for both headers and rows.
Sticky Header / Columns Place fixed-width items outside the scrollable container within the same parent row.
State-Driven Colors Use animateColorAsState() for natural, automated color morphing.
Tactile Feedback Use animateFloatAsState with spring() tied to interactionSource.collectIsPressedAsState().
Performance Use LazyColumn to efficiently recycle off-screen views for large datasets.

Frequently Asked Questions (FAQ)

Q: How do I prevent horizontal scroll jitter in Jetpack Compose tables?

A: Always hoist a single ScrollState using rememberScrollState() in your parent composable and pass that identical instance to both the header and all rows via Modifier.horizontalScroll(sharedScrollState).

Q: Can I freeze multiple sticky columns on the left?

A: Yes! Simply group your frozen column composables inside a Row placed before the horizontally scrollable Row within the parent layout.