BoxWithConstraints: Build Responsive Compose Layouts

Quick answer: use BoxWithConstraints when a component needs to change what it composes based on the space its immediate parent offers. Its content lambda exposes minWidth, maxWidth, minHeight, and maxHeight in Dp, so a card can switch from a Column to a Row when it actually has room. For app-wide navigation, panes, and screen-level decisions, prefer the Android adaptive APIs and window-size model instead.

Responsive Compose UI should react to available window space, not assume a particular device. A card may be full-width on a phone, half-width in a tablet grid, or narrow again in split-screen. BoxWithConstraints is useful because it sees the constraints of that card’s real parent—not just the device display.

What BoxWithConstraints provides

BoxWithConstraints is a Box whose content receives a BoxWithConstraintsScope:

import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable

@Composable
fun AvailableSpaceLabel() {
    BoxWithConstraints {
        Text("Available width: $maxWidth")
    }
}

Inside the lambda, the scope exposes these properties:

PropertyUnitMeaning
minWidth, maxWidthDpThe minimum and maximum width offered by the parent
minHeight, maxHeightDpThe minimum and maximum height offered by the parent
constraintspixelsThe underlying Constraints object

The Dp values are normally the right choice for UI breakpoints. Use constraints only when a lower-level layout calculation genuinely needs pixel values. The BoxWithConstraints scope reference documents both forms.

The key phrase is offered by the parent. maxWidth is not automatically the screen width. It can be reduced by padding, a navigation rail, a multi-pane layout, a dialog, a grid cell, or a split-screen window. That local awareness is precisely what makes the composable useful.

A responsive card: stack when narrow, split when wide

Here is a card that changes its composition when it has enough width for media and text to sit side-by-side:

import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun ProductCard(
    title: String,
    description: String,
    image: @Composable () -> Unit,
    modifier: Modifier = Modifier,
) {
    Card(modifier = modifier) {
        BoxWithConstraints(
            modifier = Modifier.fillMaxWidth(),
        ) {
            if (maxWidth < 480.dp) {
                Column {
                    image()
                    CardCopy(title, description)
                }
            } else {
                Row {
                    image()
                    CardCopy(title, description)
                }
            }
        }
    }
}

@Composable
private fun CardCopy(
    title: String,
    description: String,
) {
    Column(Modifier.padding(16.dp)) {
        Text(text = title, style = MaterialTheme.typography.titleMedium)
        Text(text = description, style = MaterialTheme.typography.bodyMedium)
    }
}

The 480.dp threshold is an example, not an Android standard. Choose a breakpoint from the component’s actual content needs: its image width, readable line length, required button space, and padding. Preview the component at widths just below and just above that value before treating it as a design rule.

The Android responsive-layout guidance uses this same pattern when a content-level composable should replace one layout with another.

Responsive versus adaptive changes

It helps to distinguish two kinds of changes:

NeedPrefer
Make the same UI fit betterModifiers, weight, fillMaxWidth, LazyVerticalGrid(GridCells.Adaptive(...)), or a custom layout
Replace a local component’s structureBoxWithConstraints
Change app navigation, panes, or major screen structureWindow-size/adaptive APIs

For example, a tag row that simply needs to wrap does not need BoxWithConstraints; a FlowRow or appropriate layout modifier may express the design better. A grid whose number of columns should grow with width can use GridCells.Adaptive instead of manually branching.

Use BoxWithConstraints when the available width determines a genuinely different component arrangement or different content. It is not required merely because a component is expected to look good at many sizes.

Local constraints beat device-size checks

Avoid branching on a device model, orientation, or deprecated display APIs inside a reusable component. A component has no guarantee that its parent fills the window.

Consider an email preview in a two-pane tablet layout. Even if the window is wide, the preview card may occupy only one pane. BoxWithConstraints lets the card choose its compact form based on the width it actually receives:

@Composable
fun MessagePreview(
    modifier: Modifier = Modifier,
) {
    BoxWithConstraints(modifier) {
        when {
            maxWidth < 360.dp -> CompactMessagePreview()
            maxWidth < 600.dp -> StandardMessagePreview()
            else -> ExpandedMessagePreview()
        }
    }
}

Each branch is a normal composable. Keep shared state outside the branch when it must survive a size change; otherwise a switch from CompactMessagePreview to StandardMessagePreview creates a different composition subtree and any local remember state can be discarded.

For screen-level state, hoist the state and pass it to each layout variation. The same principle appears in State Hoisting in Compose: state should have a clear owner rather than being duplicated in presentation details.

Measure the right container

The wrapper must be placed where the decision should happen. These two placements answer different questions:

// Decides based on the whole screen's available space.
BoxWithConstraints(Modifier.fillMaxSize()) {
    ScreenContent()
}

// Decides based on one card's available space in its parent.
Card {
    BoxWithConstraints(Modifier.fillMaxWidth()) {
        ProductCardContent()
    }
}

In a LazyVerticalGrid, a BoxWithConstraints inside an item sees the item cell. That is usually correct for a card that must adapt to cell width. At a screen root, it sees the screen’s available area after its parent’s insets and other layout choices.

This relationship follows the parent-to-child constraint model covered in Constraints in Jetpack Compose. A child responds to the bounds its parent offers; it does not independently claim space from the display.

Be deliberate about composition cost

BoxWithConstraints needs its constraints before it can compose its content. Android’s current adaptive-layout guidance notes that this defers composition to the layout phase and performs additional layout work.

That does not make it unsuitable. For one meaningful responsive component, the clarity is often worth it. But avoid putting a branch-heavy BoxWithConstraints around every item in a long lazy list or using it to solve simple size changes that modifiers already handle. The cost can be amplified when many instances are visible or when changes cause substantial subtrees to be recomposed.

Prefer a stable layout if the only difference is spacing, width, or alignment:

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert

Row(
    modifier = Modifier.fillMaxWidth(),
) {
    Text(
        text = "Title",
        modifier = Modifier.weight(1f),
    )
    IconButton(onClick = { /* action */ }) {
        Icon(Icons.Default.MoreVert, contentDescription = "More options")
    }
}

There is no need to branch on maxWidth if weight() already gives the title the remaining width. For related sizing behavior, see fillMaxWidth, wrapContentSize, weight, and size.

propagateMinConstraints is a specialized option

Like Box, BoxWithConstraints has a propagateMinConstraints parameter. It defaults to false, which means minimum constraints from the parent are not automatically passed to the content.

Most UI does not need to change that default. Consider propagateMinConstraints = true only when the child must know about a non-zero minimum imposed by the parent, such as a constrained custom child that should honor a required minimum size. Test the exact behavior in the component’s real parent before relying on it.

BoxWithConstraints(
    propagateMinConstraints = true,
) {
    // Child content can receive the box's minimum constraints.
}

When to use the adaptive APIs instead

BoxWithConstraints is a local layout tool. It should not become the entire app’s adaptive architecture.

For an app whose navigation changes from a bottom bar to a navigation rail, or whose primary workflow becomes a list-detail pane, use Android’s adaptive guidance and Material 3 Adaptive components. NavigationSuiteScaffold, ListDetailPaneScaffold, and SupportingPaneScaffold encode established adaptive patterns and keep behavior consistent across the app.

The adaptive do’s and don’ts recommends these APIs for navigation and canonical pane layouts. The Scaffold and window-insets guide is a useful companion when the screen shell and system bars are part of the design.

Test the boundary, not only a device

For every BoxWithConstraints breakpoint, preview or test at least three widths:

  1. Just below the breakpoint.
  2. Exactly at the breakpoint.
  3. Just above the breakpoint.

Also test the component in its actual parent: a padded screen, grid cell, dialog, and split pane can provide very different constraints. Include long localized text and larger font scaling, because a wide layout that fits English copy can still need the compact form in another language.

Common mistakes

Treating maxWidth as device width

It is the maximum width offered by the immediate parent. A reusable component should embrace that local context.

Replacing small spacing changes with a new composition

Use modifiers and layout weights for ordinary resizing. Branch only when the component’s structure or content really changes.

Losing state at a breakpoint

Keep state in a caller, screen state holder, or a rememberSaveable owner above the branch if both variations represent the same user task.

Using arbitrary breakpoints without content checks

Choose thresholds from the component’s minimum viable layout, then verify them with previews and font-scale tests.

The practical rule

BoxWithConstraints answers a local question: “What space does this parent offer this component right now?” It is excellent when that answer changes the component’s composition. If the answer only changes a measurement, use ordinary Compose layout tools. If it changes the application’s navigation or pane model, use the Android adaptive APIs.