SuriDevs Logo
Jetpack Compose Layouts: When to Use Row, Column, Box & ConstraintLayout

Jetpack Compose Layouts: When to Use Row, Column, Box & ConstraintLayout

By Sagar Maiyad  Mar 19, 2026

Every time I start a new screen in Compose, I ask myself the same question: Row, Column, Box, or ConstraintLayout?

After building 40+ production screens across multiple apps, I've developed a pretty clear mental model for which layout to use when. The short version: Row, Column, and Box handle about 90% of what you'll ever need. ConstraintLayout fills the remaining 10% — but that 10% would be miserable without it.

Here's what I've learned.

Row, Column, Box — When to Use Each

These three are your workhorses. If you're coming from XML, think of them as replacements for LinearLayout(horizontal), LinearLayout(vertical), and FrameLayout.

Column — items stacked vertically. Use it for forms, lists of settings, card content, basically anything that reads top to bottom.

Column(modifier = Modifier.padding(16.dp)) {
    Text("Order #1234", style = MaterialTheme.typography.titleMedium)
    Spacer(modifier = Modifier.height(4.dp))
    Text("Placed on March 15, 2026", style = MaterialTheme.typography.bodySmall)
    Spacer(modifier = Modifier.height(12.dp))
    Text("3 items · $47.99", style = MaterialTheme.typography.bodyMedium)
}

Row — items laid out horizontally. Profile headers, action button groups, icon + text combos.

Row(
    modifier = Modifier.fillMaxWidth(),
    verticalAlignment = Alignment.CenterVertically
) {
    AsyncImage(
        model = user.avatarUrl,
        contentDescription = null,
        modifier = Modifier
            .size(48.dp)
            .clip(CircleShape)
    )
    Spacer(modifier = Modifier.width(12.dp))
    Column {
        Text(user.displayName, fontWeight = FontWeight.SemiBold)
        Text(user.role, style = MaterialTheme.typography.bodySmall)
    }
}

Box — items stacked on top of each other (z-axis). Overlays, badges, loading states on top of content.

Box(modifier = Modifier.fillMaxSize()) {
    // Content underneath
    LazyColumn { /* ... */ }

    // FAB on top
    FloatingActionButton(
        onClick = { /* ... */ },
        modifier = Modifier
            .align(Alignment.BottomEnd)
            .padding(16.dp)
    ) {
        Icon(Icons.Default.Add, contentDescription = "Add")
    }
}

My rule: if items go down the screen, Column. Side by side, Row. On top of each other, Box. You'd be surprised how far this gets you.

ConstraintLayout in Compose — When You Actually Need It

Here's the thing most tutorials don't tell you: you probably don't need ConstraintLayout in Compose.

In XML, ConstraintLayout solved a real problem — deeply nested LinearLayout and RelativeLayout trees that killed performance. But Compose's layout system is fundamentally different. Nesting Row inside Column inside Box doesn't create the same performance overhead because Compose uses a single-pass measurement system.

So when do I actually reach for ConstraintLayout in Compose?

1. Complex cross-references between siblings. When View A's position depends on View B's size, and View C needs to align with both. Chains of Row and Column start to feel like you're fighting the framework.

2. Overlapping views with constraint-based positioning. Box handles simple overlaps, but when you need "this view's bottom aligns with that view's center, offset by 8dp" — that's constraint territory.

3. Barrier-based layouts. When you need a dynamic boundary that adjusts based on multiple views. There's no Row/Column equivalent for barriers.

4. Percentage-based positioning. Guidelines at 30% from the left, for example. You can do math with fillMaxWidth(0.3f), but guidelines are cleaner for complex cases.

For everything else — and I mean the vast majority of screens — Row, Column, and Box are simpler, more readable, and perform the same.

If you're working with XML layouts and wondering when ConstraintLayout makes sense there, I wrote about ConstraintLayout decision-making for traditional Android views.

Arrangement & Alignment

This confused me for weeks when I started with Compose. Here's the simple version:

Arrangement = how items are distributed along the main axis (horizontal for Row, vertical for Column).

Alignment = how items are positioned on the cross axis.

// Items spaced evenly across the row, centered vertically
Row(
    modifier = Modifier.fillMaxWidth(),
    horizontalArrangement = Arrangement.SpaceEvenly,
    verticalAlignment = Alignment.CenterVertically
) {
    Icon(Icons.Default.Home, contentDescription = "Home")
    Icon(Icons.Default.Search, contentDescription = "Search")
    Icon(Icons.Default.Person, contentDescription = "Profile")
}

The arrangements I use most:

Arrangement What it does When I use it
SpaceBetween First and last items touch edges, equal space between Tab bars, toolbars
SpaceEvenly Equal space everywhere including edges Icon rows, button groups
Center All items centered together Login forms, empty states
End Items pushed to the end Action buttons in dialogs
spacedBy(8.dp) Fixed gap between items Most common — lists, cards

Arrangement.spacedBy() is the one I use 80% of the time. Fixed spacing between items. Clean and predictable.

Weight & Sizing

Weight in Compose works like layout_weight in XML's LinearLayout, but the API is cleaner:

Row(modifier = Modifier.fillMaxWidth()) {
    // Takes 70% of the width
    Text(
        text = product.name,
        modifier = Modifier.weight(0.7f),
        maxLines = 1,
        overflow = TextOverflow.Ellipsis
    )

    // Takes 30% of the width
    Text(
        text = product.price,
        modifier = Modifier.weight(0.3f),
        textAlign = TextAlign.End
    )
}

The gotcha I hit early on: weight is only available inside Row and Column scope. You can't use it inside Box. This makes sense — Box stacks things on top of each other, so distributing width doesn't apply.

Another pattern I use constantly — one item takes remaining space, others wrap their content:

Row(
    modifier = Modifier.fillMaxWidth(),
    verticalAlignment = Alignment.CenterVertically
) {
    Text(
        text = "Settings",
        modifier = Modifier.weight(1f)  // Takes all remaining space
    )
    Switch(
        checked = isEnabled,
        onCheckedChange = { isEnabled = it }
    )
}

The weight(1f) on the text pushes the switch to the far right. No spacers needed.

Advanced Layouts with ConstraintLayout in Compose

When you do need ConstraintLayout, here's how the XML concepts translate.

Setup

dependencies {
    implementation("androidx.constraintlayout:constraintlayout-compose:1.1.1")
}

Chains

Link multiple composables for distribution — like SpaceEvenly in Row, but with more control:

ConstraintLayout(modifier = Modifier.fillMaxWidth()) {
    val (btn1, btn2, btn3) = createRefs()

    createHorizontalChain(btn1, btn2, btn3, chainStyle = ChainStyle.SpreadInside)

    Button(
        onClick = { },
        modifier = Modifier.constrainAs(btn1) {
            top.linkTo(parent.top)
        }
    ) { Text("Save") }

    Button(
        onClick = { },
        modifier = Modifier.constrainAs(btn2) {
            top.linkTo(parent.top)
        }
    ) { Text("Edit") }

    Button(
        onClick = { },
        modifier = Modifier.constrainAs(btn3) {
            top.linkTo(parent.top)
        }
    ) { Text("Delete") }
}

Chain styles: Spread (equal gaps), SpreadInside (edges pinned, gaps between), Packed (grouped together).

Honestly, for most chain use cases in Compose, Row with Arrangement.SpaceEvenly or SpaceBetween does the same thing with less code.

Guidelines

Invisible anchor lines at a percentage position:

ConstraintLayout(modifier = Modifier.fillMaxSize()) {
    val guideline = createGuidelineFromStart(fraction = 0.35f)
    val (image, content) = createRefs()

    Image(
        painter = painterResource(R.drawable.product),
        contentDescription = null,
        modifier = Modifier.constrainAs(image) {
            start.linkTo(parent.start)
            end.linkTo(guideline)
            width = Dimension.fillToConstraints
        }
    )

    Column(
        modifier = Modifier.constrainAs(content) {
            start.linkTo(guideline, margin = 12.dp)
            end.linkTo(parent.end)
            top.linkTo(parent.top)
            width = Dimension.fillToConstraints
        }
    ) {
        Text("Product Title", fontWeight = FontWeight.Bold)
        Text("Description goes here")
    }
}

The image gets 35% of the width, content gets the rest. Resizes on any screen without breakpoints.

Barriers

This is where ConstraintLayout genuinely shines — no Row/Column equivalent exists:

ConstraintLayout(modifier = Modifier.fillMaxWidth()) {
    val (label1, label2, input1, input2) = createRefs()

    val barrier = createEndBarrier(label1, label2)

    Text(
        text = "Name",
        modifier = Modifier.constrainAs(label1) {
            start.linkTo(parent.start)
            top.linkTo(parent.top)
        }
    )

    Text(
        text = "Email Address",
        modifier = Modifier.constrainAs(label2) {
            start.linkTo(parent.start)
            top.linkTo(label1.bottom, margin = 16.dp)
        }
    )

    OutlinedTextField(
        value = name,
        onValueChange = { name = it },
        modifier = Modifier.constrainAs(input1) {
            start.linkTo(barrier, margin = 12.dp)
            end.linkTo(parent.end)
            top.linkTo(label1.top)
            width = Dimension.fillToConstraints
        }
    )

    OutlinedTextField(
        value = email,
        onValueChange = { email = it },
        modifier = Modifier.constrainAs(input2) {
            start.linkTo(barrier, margin = 12.dp)
            end.linkTo(parent.end)
            top.linkTo(label2.top)
            width = Dimension.fillToConstraints
        }
    )
}

The barrier sits after whichever label is wider. Both input fields align perfectly regardless of label text length. Try doing this cleanly with just Row and Column — it's ugly.

Real Example: Product Card

Here's a product card I built for an e-commerce app. It uses ConstraintLayout because the price, discount badge, and cart button have cross-references that nested Row/Column couldn't handle cleanly:

@Composable
fun ProductCard(product: Product, onAddToCart: () -> Unit) {
    ConstraintLayout(
        modifier = Modifier
            .fillMaxWidth()
            .padding(12.dp)
    ) {
        val (image, title, price, originalPrice, badge, cartBtn) = createRefs()

        // Square product image
        AsyncImage(
            model = product.imageUrl,
            contentDescription = product.name,
            modifier = Modifier
                .constrainAs(image) {
                    top.linkTo(parent.top)
                    start.linkTo(parent.start)
                    end.linkTo(parent.end)
                    width = Dimension.fillToConstraints
                }
                .aspectRatio(1f)
                .clip(RoundedCornerShape(8.dp)),
            contentScale = ContentScale.Crop
        )

        // Discount badge overlapping image corner
        if (product.discountPercent > 0) {
            Text(
                text = "-${product.discountPercent}%",
                color = Color.White,
                fontSize = 12.sp,
                fontWeight = FontWeight.Bold,
                modifier = Modifier
                    .constrainAs(badge) {
                        top.linkTo(image.top, margin = 8.dp)
                        end.linkTo(image.end, margin = 8.dp)
                    }
                    .background(Color(0xFFE53935), RoundedCornerShape(4.dp))
                    .padding(horizontal = 6.dp, vertical = 2.dp)
            )
        }

        // Product title
        Text(
            text = product.name,
            fontWeight = FontWeight.SemiBold,
            maxLines = 2,
            overflow = TextOverflow.Ellipsis,
            modifier = Modifier.constrainAs(title) {
                top.linkTo(image.bottom, margin = 8.dp)
                start.linkTo(parent.start)
                end.linkTo(parent.end)
                width = Dimension.fillToConstraints
            }
        )

        // Sale price
        Text(
            text = "$${product.salePrice}",
            fontWeight = FontWeight.Bold,
            fontSize = 18.sp,
            modifier = Modifier.constrainAs(price) {
                top.linkTo(title.bottom, margin = 6.dp)
                start.linkTo(parent.start)
            }
        )

        // Original price with strikethrough
        Text(
            text = "$${product.originalPrice}",
            textDecoration = TextDecoration.LineThrough,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
            fontSize = 14.sp,
            modifier = Modifier.constrainAs(originalPrice) {
                baseline.linkTo(price.baseline)
                start.linkTo(price.end, margin = 8.dp)
            }
        )

        // Add to cart
        IconButton(
            onClick = onAddToCart,
            modifier = Modifier.constrainAs(cartBtn) {
                top.linkTo(price.top)
                bottom.linkTo(price.bottom)
                end.linkTo(parent.end)
            }
        ) {
            Icon(Icons.Default.ShoppingCart, contentDescription = "Add to cart")
        }
    }
}

The baseline alignment between sale price and original price, the badge overlapping the image corner, and the cart button vertically centered against the price — all of that would need multiple nested Box/Row/Column combos. ConstraintLayout keeps it flat and readable.

Common Mistakes

1. Using ConstraintLayout when Row/Column works fine

I see this constantly in code reviews. A simple vertical form doesn't need constraints:

// Overkill — just use Column
ConstraintLayout {
    val (title, subtitle, button) = createRefs()
    // ...12 lines of constraint wiring for 3 views in a column
}

// Better
Column(modifier = Modifier.padding(16.dp)) {
    Text("Title")
    Text("Subtitle")
    Button(onClick = { }) { Text("Submit") }
}

2. Forgetting Dimension.fillToConstraints

In ConstraintLayout, if you want a view to fill the space between its start and end constraints, you need width = Dimension.fillToConstraints. Without it, the view wraps its content and your constraints for positioning still apply, but the sizing doesn't.

// Won't fill the width between constraints
modifier = Modifier.constrainAs(input) {
    start.linkTo(barrier)
    end.linkTo(parent.end)
}

// Will fill — add this
modifier = Modifier.constrainAs(input) {
    start.linkTo(barrier)
    end.linkTo(parent.end)
    width = Dimension.fillToConstraints  // This is the fix
}

3. Nesting Row inside Row inside Column

Three levels of nesting in Compose is a code smell. If you're there, step back and consider:

  • Can you extract a composable function?
  • Would ConstraintLayout be cleaner?
  • Are you overcomplicating the design?

4. Hardcoding sizes instead of using weight

// Fragile — breaks on different screen sizes
Row {
    Text(modifier = Modifier.width(250.dp))
    Button(modifier = Modifier.width(100.dp))
}

// Responsive — works everywhere
Row {
    Text(modifier = Modifier.weight(1f))
    Button(onClick = { }) { Text("Go") }
}

5. Using fillMaxWidth() inside a weighted Row

If a child already has weight, adding fillMaxWidth() does nothing — weight already determines the width. I've seen both applied together too many times.

Performance: Nested vs Flat in Compose

Here's the thing XML developers need to unlearn: nesting in Compose is not the same performance problem as nesting in XML.

XML's LinearLayout inside LinearLayout caused exponential measure/layout passes. Each level could trigger multiple measurements of its children. A 5-level deep hierarchy could mean dozens of measure passes.

Compose's layout system enforces a single-pass measurement. Each composable is measured exactly once. Nesting Row inside Column inside Box doesn't multiply measurements.

That said, unnecessary nesting still has costs:

  • More composable nodes in the tree
  • More recomposition scope boundaries
  • Harder to read and maintain

My guidelines:

  • 1-2 levels of nesting: totally fine, don't even think about it
  • 3 levels: probably fine, but consider extracting composables
  • 4+ levels: refactor — either extract or consider ConstraintLayout

I've never profiled a Compose app and found that layout nesting was the bottleneck. If your app is slow, look at recomposition count, image loading, and list performance first.

Quick Decision Guide

Need to stack items vertically?          → Column
Need items side by side?                 → Row
Need to overlay items?                   → Box
Need cross-references between siblings?  → ConstraintLayout
Need barrier-based alignment?            → ConstraintLayout
Need percentage positioning?             → ConstraintLayout (or fillMaxWidth fraction)
Building a form with aligned labels?     → ConstraintLayout
Everything else?                         → Row + Column + Box

Start simple. Add complexity only when the simpler approach gets awkward. I've refactored dozens of ConstraintLayouts back to Row/Column when I realized the constraints were overkill.

For setting up navigation between your layout screens, check out the Jetpack Compose Navigation tutorial with type-safe routes and popUpTo. And if you're structuring your ViewModels behind these screens, the MVVM Architecture guide covers the state management patterns I use in production.

Keep Reading

Android Jetpack Compose UI Design Kotlin

Author

Sagar Maiyad
Written By
Sagar Maiyad

Sagar Maiyad - Android developer specializing in Kotlin, Jetpack Compose, and modern Android architecture. Sharing practical tutorials and real-world development insights.

View All Posts →

Related Posts

Latest Tags