Jetpack Compose Tab Layout

Jetpack Compose Tab Layout is something you'll reach for in almost every Android app you build. Whether you're organizing a news feed by category, separating profile sections, or building a multi-section settings screen, tabs give users a clean and familiar way to switch between content. In this guide you'll learn how to build a Jetpack Compose Tab Layout from scratch — covering TabRow, state management, icons, scrollable tabs, custom indicators, and pager synchronization with hands-on code examples every step of the way.

What TabRow Does in Jetpack Compose

TabRow is the composable responsible for rendering a horizontal strip of selectable tabs across the top of a screen. You give it the currently selected index and a block of Tab composables, and it handles the rest — including the animated underline indicator that slides to the active tab.

The reason you reach for TabRow rather than building something yourself with a Row and buttons is that it bakes in Material Design semantics automatically: the ripple feedback, the accessibility roles, the color transitions, and the animated indicator are all handled for you. You only manage one piece of state — which tab is selected.

The official Material3 TabRow documentation shows the full signature, but its minimal form looks like this:

import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.*

@Composable
fun MinimalTabRow() {
    var selectedIndex by remember { mutableStateOf(0) }
    TabRow(selectedTabIndex = selectedIndex) {
        Tab(selected = true, onClick = {}, text = { Text("Tab One") })
        Tab(selected = false, onClick = {}, text = { Text("Tab Two") })
    }
}

That is the skeleton. Everything you build from here adds onto that pattern.

Project Setup for Compose Tab Layout

Before writing any tab composables, your project needs the correct dependencies. Open your app-level build.gradle.kts and confirm the following are present:

// build.gradle.kts (app level)
android {
    compileSdk = 34
    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.13"
    }
}

dependencies {
    implementation("androidx.compose.material3:material3:1.2.1")
    implementation("androidx.compose.ui:ui:1.6.7")
    implementation("androidx.compose.ui:ui-tooling-preview:1.6.7")
    implementation("androidx.activity:activity-compose:1.9.0")
    implementation("androidx.compose.foundation:foundation:1.6.7")
    implementation("androidx.compose.material:material-icons-extended:1.6.7")
}

The material-icons-extended dependency gives you access to icons like Home, Search, and Favorite that commonly appear in tab bars. The foundation module is what provides HorizontalPager, which you'll use later to sync swipe gestures with tab selection.

After syncing, your project is ready for any Jetpack Compose Tab Layout pattern in this guide.

Building Your First Compose Tab Layout

The simplest working Jetpack Compose Tab Layout has a list of tab labels, a selected index stored in state, and content that swaps based on which tab is active. Here is that pattern fully written out:

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun BasicTabLayout() {
    val tabs = listOf("Trending", "Latest", "Saved")
    var selectedIndex by remember { mutableStateOf(0) }

    Column(modifier = Modifier.fillMaxSize()) {
        TabRow(selectedTabIndex = selectedIndex) {
            tabs.forEachIndexed { index, title ->
                Tab(
                    selected = selectedIndex == index,
                    onClick = { selectedIndex = index },
                    text = { Text(title) }
                )
            }
        }
        when (selectedIndex) {
            0 -> Text("Trending content loads here", modifier = Modifier.padding(16.dp))
            1 -> Text("Latest articles load here", modifier = Modifier.padding(16.dp))
            2 -> Text("Your saved posts load here", modifier = Modifier.padding(16.dp))
        }
    }
}

Run this and you get three tabs across the top of the screen. Tap Trending and the content area shows the trending message; tap Latest and it switches instantly. The animated underline slides to the active tab automatically — no extra animation code needed. Everything is driven by that single integer selectedIndex.

State Management for Compose Tab Layout

The state pattern for Jetpack Compose Tab Layout is always the same shape: one integer, one mutableStateOf, and one lambda. The interesting design question is where that state lives.

When tab selection only affects the current screen, you keep state local with remember. When a parent composable needs to react to tab changes — say, to sync a bottom bar or trigger a network call — you hoist the state up. Here is how state hoisting looks for tabs:

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun HoistedTabLayout(
    selectedIndex: Int,
    onTabSelected: (Int) -> Unit
) {
    val tabs = listOf("Music", "Videos", "Podcasts")

    Column(modifier = Modifier.fillMaxSize()) {
        TabRow(selectedTabIndex = selectedIndex) {
            tabs.forEachIndexed { index, title ->
                Tab(
                    selected = selectedIndex == index,
                    onClick = { onTabSelected(index) },
                    text = { Text(title) }
                )
            }
        }
        Text(
            text = "Now viewing: ${tabs[selectedIndex]}",
            modifier = Modifier.padding(20.dp)
        )
    }
}

@Composable
fun ParentScreen() {
    var currentTab by remember { mutableStateOf(0) }
    HoistedTabLayout(
        selectedIndex = currentTab,
        onTabSelected = { currentTab = it }
    )
}

The child composable becomes purely display logic — it receives what to show and reports what the user did. The parent owns the truth. This also means you can drive the same tab bar from different sources, like a deep link or a notification tap, by simply updating the integer in the parent.

Icon Tabs in Jetpack Compose

Icon-only tabs and icon-plus-text tabs are both extremely common in production apps. The Tab composable accepts both icon and text parameters, and you can mix and match. Using both gives the richest experience — the icon draws the eye and the label removes ambiguity.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp

data class TabItem(val title: String, val icon: ImageVector)

@Composable
fun IconTabLayout() {
    val tabItems = listOf(
        TabItem("Home", Icons.Filled.Home),
        TabItem("Favorites", Icons.Filled.Favorite),
        TabItem("Profile", Icons.Filled.Person)
    )
    var selectedIndex by remember { mutableStateOf(0) }

    Column(modifier = Modifier.fillMaxSize()) {
        TabRow(selectedTabIndex = selectedIndex) {
            tabItems.forEachIndexed { index, item ->
                Tab(
                    selected = selectedIndex == index,
                    onClick = { selectedIndex = index },
                    text = { Text(item.title) },
                    icon = {
                        Icon(
                            imageVector = item.icon,
                            contentDescription = item.title
                        )
                    }
                )
            }
        }
        Text(
            text = "Selected: ${tabItems[selectedIndex].title}",
            modifier = Modifier.padding(20.dp)
        )
    }
}

When a tab is selected, both its icon and label automatically switch to the primary color defined in your MaterialTheme. The unselected tabs render in a subdued secondary color. This color behavior is built into Material3 — you do not have to specify it manually.

Scrollable Tab Layout with ScrollableTabRow

When your app has more tabs than comfortably fit across the screen width — a news app with eight categories, for example — a fixed TabRow squishes every label into an illegible mess. ScrollableTabRow solves this by making the tab strip horizontally scrollable while keeping the selected tab indicator behavior identical.

The ScrollableTabRow documentation shows that it accepts the same children as TabRow, with one extra parameter — edgePadding — that controls the breathing room at both ends of the strip.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun ScrollableTabsDemo() {
    val categories = listOf(
        "Technology", "Sports", "Health", "Finance",
        "Travel", "Science", "Entertainment", "Politics"
    )
    var selectedIndex by remember { mutableStateOf(0) }

    Column(modifier = Modifier.fillMaxSize()) {
        ScrollableTabRow(
            selectedTabIndex = selectedIndex,
            edgePadding = 12.dp
        ) {
            categories.forEachIndexed { index, category ->
                Tab(
                    selected = selectedIndex == index,
                    onClick = { selectedIndex = index },
                    text = { Text(category) }
                )
            }
        }
        Text(
            text = "Category: ${categories[selectedIndex]}",
            modifier = Modifier.padding(20.dp)
        )
    }
}

Swipe the tab strip left to reveal tabs that were off-screen. Tapping any one of them scrolls the strip so the selected tab is fully visible and shows the indicator underneath it. Switching from TabRow to ScrollableTabRow is a single-word change — the rest of your code stays exactly the same.

Custom Tab Indicator in Compose

The default indicator is a thin line at the bottom of the selected tab. Material3 lets you replace this entirely using the indicator parameter on TabRow. A capsule or pill-shaped indicator that fills the entire tab area is a popular alternative in modern app designs.

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

@Composable
fun CustomIndicatorTabLayout() {
    val tabs = listOf("Overview", "Details", "Reviews")
    var selectedIndex by remember { mutableStateOf(0) }

    Column(modifier = Modifier.fillMaxSize()) {
        TabRow(
            selectedTabIndex = selectedIndex,
            indicator = { tabPositions ->
                val position = tabPositions[selectedIndex]
                Box(
                    modifier = Modifier
                        .tabIndicatorOffset(position)
                        .fillMaxWidth()
                        .height(4.dp)
                        .background(
                            color = Color(0xFFE91E63),
                            shape = RoundedCornerShape(
                                topStart = 4.dp,
                                topEnd = 4.dp
                            )
                        )
                )
            }
        ) {
            tabs.forEachIndexed { index, title ->
                Tab(
                    selected = selectedIndex == index,
                    onClick = { selectedIndex = index },
                    text = { Text(title) }
                )
            }
        }
        Text(
            text = "Viewing: ${tabs[selectedIndex]}",
            modifier = Modifier.padding(20.dp)
        )
    }
}

The tabIndicatorOffset modifier is a Compose built-in that reads the measured bounds of the selected tab and positions your custom composable to match exactly. You can use any color, corner radius, height, or even animated properties inside that Box — the indicator slot is fully open to customization.

Syncing Compose Tab Layout with HorizontalPager

The most complete version of a Jetpack Compose Tab Layout synchronizes the tab strip with a swipeable HorizontalPager. When the user swipes left or right on the page content, the tab indicator tracks them. When the user taps a tab, the page animates smoothly to the correct position. Neither interaction breaks the other.

HorizontalPager is part of the compose foundation module and works through a shared PagerState. The HorizontalPager documentation explains the full API, but the key insight is that pagerState.currentPage is a reactive integer — exactly the same shape as the selected tab index. You can drive both from the same value.

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TabsWithPager() {
    val tabs = listOf("Feed", "Explore", "Notifications")
    val pagerState = rememberPagerState(pageCount = { tabs.size })
    val coroutineScope = rememberCoroutineScope()

    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(title) }
                )
            }
        }
        HorizontalPager(
            state = pagerState,
            modifier = Modifier.fillMaxSize()
        ) { page ->
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                Text(text = "${tabs[page]} page content")
            }
        }
    }
}

When the user swipes the pager, pagerState.currentPage updates automatically and the TabRow recomposes to show the correct indicator position. When the user taps a tab, the coroutine calls animateScrollToPage, which smoothly scrolls the pager. The synchronization is complete — two interaction paths, one shared state.

Note that animateScrollToPage is a suspend function and must be called inside a coroutine. That is why rememberCoroutineScope is used here — it gives you a scope tied to the composable's lifecycle so the animation cancels safely if the composable leaves the composition.

Full Working Compose Tab Layout — Pager, Icons, and Themed Pages

This final example assembles every concept from the guide into one complete runnable file: a three-tab layout with icons, HorizontalPager synchronization, and color-themed content pages for each tab.

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch

data class AppTab(
    val label: String,
    val icon: ImageVector,
    val backgroundColor: Color,
    val description: String
)

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FullTabLayoutDemo() {
    val appTabs = listOf(
        AppTab(
            label = "Home",
            icon = Icons.Filled.Home,
            backgroundColor = Color(0xFFE8F5E9),
            description = "Welcome to your home feed"
        ),
        AppTab(
            label = "Search",
            icon = Icons.Filled.Search,
            backgroundColor = Color(0xFFE3F2FD),
            description = "Discover new content here"
        ),
        AppTab(
            label = "Likes",
            icon = Icons.Filled.Favorite,
            backgroundColor = Color(0xFFFCE4EC),
            description = "All your liked items live here"
        )
    )

    val pagerState = rememberPagerState(pageCount = { appTabs.size })
    val coroutineScope = rememberCoroutineScope()

    Column(modifier = Modifier.fillMaxSize()) {
        TabRow(
            selectedTabIndex = pagerState.currentPage,
            containerColor = MaterialTheme.colorScheme.surface,
            contentColor = MaterialTheme.colorScheme.primary
        ) {
            appTabs.forEachIndexed { index, tab ->
                Tab(
                    selected = pagerState.currentPage == index,
                    onClick = {
                        coroutineScope.launch {
                            pagerState.animateScrollToPage(index)
                        }
                    },
                    text = { Text(tab.label) },
                    icon = {
                        Icon(
                            imageVector = tab.icon,
                            contentDescription = tab.label
                        )
                    }
                )
            }
        }

        HorizontalPager(
            state = pagerState,
            modifier = Modifier.fillMaxSize()
        ) { page ->
            val tab = appTabs[page]
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .background(tab.backgroundColor)
                    .padding(32.dp),
                verticalArrangement = Arrangement.Center,
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Icon(
                    imageVector = tab.icon,
                    contentDescription = null,
                    modifier = Modifier.size(72.dp),
                    tint = MaterialTheme.colorScheme.primary
                )
                Spacer(modifier = Modifier.height(20.dp))
                Text(
                    text = tab.label,
                    fontSize = 30.sp,
                    fontWeight = FontWeight.Bold
                )
                Spacer(modifier = Modifier.height(10.dp))
                Text(
                    text = tab.description,
                    fontSize = 16.sp,
                    color = Color.DarkGray
                )
            }
        }
    }
}