Compose Unstyled 2.0 is out! Check the official announcement blog ->

Tab Group

A tab group component with generic tab keys and keyboard navigation.

import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.composeunstyled.Tab
import com.composeunstyled.TabList
import com.composeunstyled.TabPanel
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledTabGroup

@Composable
fun TabGroupDemo() {
  class Article(val title: String, val relativeTime: String, val comments: Int, val points: Int)

  val categories = mapOf<String, List<Article>>(
    "Trending" to listOf(
      Article(
        title = "I hosted my startup's backend on a Tamagotchi – AMA",
        relativeTime = "11 hours ago",
        comments = 312,
        points = 1042,
      ),
      Article(
        title = "I fired myself to improve company culture — it worked",
        relativeTime = "9 hours ago",
        comments = 264,
        points = 928,
      ),
    ),
    "Latest" to listOf(
      Article(
        title = "The office microwave is now a Kubernetes node",
        relativeTime = "2 hours ago",
        comments = 87,
        points = 356,
      ),
      Article(
        title = "We replaced scrum with interpretive dancing",
        relativeTime = "1 hour ago",
        comments = 52,
        points = 198,
      ),
    ),
    "Popular" to listOf(
      Article(
        title = "Social network for ants is growing fast",
        relativeTime = "14 hours ago",
        comments = 412,
        points = 1376,
      ),
      Article(
        title = "Why I quit my $800K FAANG job to grow mushrooms",
        relativeTime = "16 hours ago",
        comments = 391,
        points = 1204,
      ),
    ),
  )

  var selectedTab by remember { mutableStateOf(categories.keys.first()) }

  Box(
    modifier = Modifier.fillMaxSize()
      .padding(16.dp)
      .padding(top = 90.dp),
    contentAlignment = Alignment.TopCenter,
  ) {
    UnstyledTabGroup(
      selectedTab = selectedTab,
      onSelectedTabChange = { selectedTab = it },
      tabs = categories.keys.toList(),
      modifier = Modifier.widthIn(max = 450.dp),
    ) {
      Column {
        TabList(
          modifier = Modifier
            .fillMaxWidth()
            .height(48.dp)
            .clip(RoundedCornerShape(8.dp))
            .background(Color(0xFFF8FAFC))
            .border(1.dp, Color(0xFFCACACA), RoundedCornerShape(8.dp)),
        ) {
          Row(Modifier.fillMaxSize()) {
            categories.forEach { (key, _) ->
              Tab(
                key = key,
                modifier = Modifier.weight(1f).fillMaxHeight(),
                indication = LocalIndication.current,
              ) {
                Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                  BasicText(
                    text = key,
                    style = TextStyle(
                      fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
                      color = if (selected) {
                        Color.Black
                      } else {
                        Color(0xFF757575)
                      },
                    ),
                  )
                  if (selected) {
                    Box(
                      modifier = Modifier
                        .background(
                          color = Color.Black,
                          shape = RoundedCornerShape(2.dp),
                        )
                        .fillMaxWidth()
                        .height(3.dp)
                        .align(Alignment.BottomCenter),
                    )
                  }
                }
              }
            }
          }
        }

        Spacer(modifier = Modifier.height(16.dp))
        categories.forEach { (key, items) ->
          TabPanel(
            key = key,
            modifier = Modifier
              .fillMaxWidth()
              .background(
                color = Color(0xFFF8FAFC),
                shape = RoundedCornerShape(8.dp),
              )
              .border(1.dp, Color(0xFFCACACA), RoundedCornerShape(8.dp)),
          ) {
            Column(Modifier.padding(16.dp)) {
              items.forEach { item ->
                UnstyledButton(
                  onClick = { /* TODO */ },
                  modifier = Modifier.clip(RoundedCornerShape(8.dp)),
                  indication = LocalIndication.current,
                ) {
                  Column(Modifier.padding(12.dp)) {
                    BasicText(item.title, style = TextStyle(fontWeight = FontWeight.Medium))
                    Spacer(Modifier.height(4.dp))
                    Row(
                      horizontalArrangement = Arrangement.spacedBy(4.dp),
                      verticalAlignment = Alignment.CenterVertically,
                      modifier = Modifier.fillMaxWidth().alpha(0.6f),
                    ) {
                      BasicText(item.relativeTime)
                      BasicText("·")
                      BasicText("${item.comments} comments")
                      BasicText("·")
                      BasicText("${item.points} shares")
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Features

  • Generic tab keys
  • Horizontal and vertical tab lists
  • Automatic or manual activation
  • Focus handoff to panels

Installation

implementation("com.composables:composeunstyled-tab-group")

Anatomy

val tabs = listOf("account", "billing")

UnstyledTabGroup(
  selectedTab = selectedTab,
  onSelectedTabChange = onSelectedTabChange,
  tabs = tabs,
) {
  TabList {
    tabs.forEach { tab ->
      Tab(tab) {
      }
    }
  }

  tabs.forEach { tab ->
    TabPanel(tab) {
    }
  }
}

Concepts

  • UnstyledTabGroup represents a set of tabs and panels grouped by key.
  • TabList renders the group of tabs.
  • Tab renders one tab inside TabList.
  • TabPanel renders the panel for the selected tab key.

Accessibility

Keep tabs in the same order as the visual tabs. TabList uses that order for arrow-key navigation, Home, and End.

Code Examples

Selecting tabs manually

Use the selectedTab parameter to control the active tab:

val tabs = listOf("account", "billing")
var selectedTab by remember { mutableStateOf("account") }

UnstyledTabGroup(
  selectedTab = selectedTab,
  onSelectedTabChange = { selectedTab = it },
  tabs = tabs,
) {
  TabList {
    tabs.forEach { tab ->
      Tab(tab) {
        BasicText(tab)
      }
    }
  }

  TabPanel("account") {
    BasicText("Account")
  }

  TabPanel("billing") {
    BasicText("Billing")
  }
}

Creating vertical tabs

Use the orientation parameter on TabList to change arrow-key navigation for vertical tabs:

TabList(orientation = Orientation.Vertical) {
  tabs.forEach { tab ->
    Tab(tab) {
      BasicText(tab)
    }
  }
}

Requiring click activation

Use the activateOnFocus parameter when arrow-key focus should not select tabs:

Tab("billing", activateOnFocus = false) {
  BasicText("Billing")
}

Disabling a tab

Use the enabled parameter to keep a tab visible but unavailable:

Tab(
  key = "billing",
  enabled = false,
) {
  BasicText("Billing")
}

API Reference

UnstyledTabGroup

Parameter Type Description
selectedTab T The initial selected tab for the tab group state.
onSelectedTabChange (T) -> Unit
tabs List<T>
modifier Modifier Modifier to be applied to the tab.
content TabGroupScope<T>.() -> Unit Composable function to define the content of the tab panel.

TabGroupScope.TabList

Parameter Type Description
modifier Modifier Modifier to be applied to the tab.
orientation Orientation The orientation of the tab list (horizontal or vertical).
content TabListScope<T>.() -> Unit Composable function to define the content of the tab panel.

TabListScope.Tab

Parameter Type Description
key T The unique key for the tab panel.
modifier Modifier Modifier to be applied to the tab.
enabled Boolean Whether the tab is enabled.
activateOnFocus Boolean Whether to activate a tab when it receives focus.
indication Indication? Visual indication for interactions.
interactionSource MutableInteractionSource? Interaction source for the tab.
content TabScope.() -> Unit Composable function to define the content of the tab panel.

TabGroupScope.TabPanel

Parameter Type Description
key T The unique key for the tab panel.
modifier Modifier Modifier to be applied to the tab.
content () -> Unit Composable function to define the content of the tab panel.