What You Will Build

A custom segmented control (tab selector) with rounded corners, highlighted selection state, and content that changes based on the selected segment. This is the Compose equivalent of iOS UISegmentedControl, commonly used for toggling between views like Daily/Weekly/Monthly.

Why This Pattern Matters

While Material 3 introduced SegmentedButton, building your own gives full design control. This pattern teaches Row-based layouts with weight distribution, conditional backgrounds, and index-based selection management.

The Complete Implementation

@Composable
fun CustomSegmentedControlScreen() {
    var selectedIndex by remember { mutableIntStateOf(0) }
    val options = listOf("Daily", "Weekly", "Monthly")

    Column(
        modifier = Modifier.fillMaxSize().padding(24.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("Custom Segmented Control",
            style = MaterialTheme.typography.headlineSmall,
            fontWeight = FontWeight.Bold)
        Spacer(Modifier.height(32.dp))

        // Segmented control
        Row(
            modifier = Modifier.fillMaxWidth()
                .clip(RoundedCornerShape(12.dp))
                .background(MaterialTheme.colorScheme.surfaceVariant)
        ) {
            options.forEachIndexed { index, label ->
                val isSelected = selectedIndex == index
                Box(
                    modifier = Modifier
                        .weight(1f)
                        .clip(RoundedCornerShape(12.dp))
                        .background(
                            if (isSelected) MaterialTheme.colorScheme.primary
                            else Color.Transparent
                        )
                        .clickable { selectedIndex = index }
                        .padding(vertical = 12.dp),
                    contentAlignment = Alignment.Center
                ) {
                    Text(
                        label,
                        color = if (isSelected) Color.White
                            else MaterialTheme.colorScheme.onSurfaceVariant,
                        fontWeight = if (isSelected) FontWeight.Bold
                            else FontWeight.Normal
                    )
                }
            }
        }

        Spacer(Modifier.height(24.dp))
        Card(Modifier.fillMaxWidth()) {
            Column(Modifier.padding(16.dp)) {
                Text("Selected: ${options[selectedIndex]}",
                    style = MaterialTheme.typography.titleMedium)
            }
        }
    }
}

Adding Animation

To animate the selection indicator sliding between segments, use animateDpAsState for the indicator offset:

val indicatorOffset by animateDpAsState(
    targetValue = (selectedIndex * segmentWidth).dp,
    animationSpec = spring(dampingRatio = 0.8f),
    label = "indicator"
)

// Use this offset with Modifier.offset(x = indicatorOffset) on the
// selection highlight Box, positioned absolutely within the Row

Tips and Pitfalls

  • weight(1f) on each segment ensures equal distribution regardless of text length.
  • Clip the outer Row and each Box with the same corner radius so the selected state fits cleanly within the track.
  • mutableIntStateOf is the correct state type for indices. Avoid mutableStateOf<Int> to prevent boxing.
  • For accessibility: Add Modifier.semantics { selected = isSelected } to each segment for screen reader support.

Min SDK: 21 | Compose BOM: 2024.01.00+

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.