Jetpack Compose Grid API: Build 2D Android Layouts

Jetpack Compose Grid API: Build 2D Android Layouts

K
Kodetra Technologies·April 28, 2026·7 min read Intermediate

Summary

New Compose 1.11 Grid composable: tracks, gaps, Fr units, adaptive 2D layouts.

On April 8, 2026, Google shipped the stable Jetpack Compose April '26 release (Compose 1.11) with a long-requested addition: a first-class non-lazy Grid composable. Until now, anything beyond a Row or Column meant either nesting them with awkward weights, reaching for LazyVerticalGrid (which is built for scrolling, not screen layout), or pulling in a third-party library like gridpad-android.

This guide walks through the new Grid API end to end. By the time you finish, you will have a real adaptive product card screen that reflows from phone to foldable to tablet, using tracks, fractional units, gaps, and explicit spans. We will also cover the gotchas that tripped up early adopters during the 1.11 beta — measurement order, weight semantics versus Fr, and why the Grid is not a drop-in replacement for LazyVerticalGrid.

Prerequisites

  • Android Studio Panda 4 (April 2026) or newer.
  • Kotlin 2.1.20 or newer; AGP 8.9 or newer.
  • An Android project that already uses Jetpack Compose. If you are starting fresh, run File → New → New Project → Empty Activity (Compose).
  • Familiarity with composables, modifiers, and the @Preview annotation.

Step 1 — Add Compose 1.11 to your project

Update the Compose BOM to the April '26 release. Inside your module-level build.gradle.kts:

dependencies {
    implementation(platform("androidx.compose:compose-bom:2026.04.01"))
    implementation("androidx.compose.foundation:foundation")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.ui:ui-tooling-preview")
    debugImplementation("androidx.compose.ui:ui-tooling")
}

The Grid composable lives in androidx.compose.foundation.layout, so no extra artifact is needed once you are on BOM 2026.04.01. Sync Gradle and you are ready.

Step 2 — Your first Grid: a 3×3 dashboard

Let's start with the smallest useful example: a fixed 3×3 dashboard of stat tiles. Three columns of 160.dp, three rows of 90.dp, and an 8.dp gap between every cell.

import androidx.compose.foundation.layout.Grid
import androidx.compose.foundation.layout.column
import androidx.compose.foundation.layout.row
import androidx.compose.foundation.layout.rowGap
import androidx.compose.foundation.layout.columnGap

@Composable
fun StatGrid() {
    Grid(
        config = {
            repeat(3) { column(160.dp) }
            repeat(3) { row(90.dp) }
            rowGap(8.dp)
            columnGap(8.dp)
        },
    ) {
        repeat(9) { i ->
            StatTile(label = "Metric ${i + 1}")
        }
    }
}

Children fill cells in row-major order by default — left to right, top to bottom — exactly like CSS Grid's auto-placement. With nine children and a 3×3 track grid, every cell gets exactly one tile. No Modifier.weight(), no nested rows, no math.

Step 3 — Tracks, gaps, and Fr units

Fixed dp tracks are fine for icons and tiles, but real screens need to flex. The Grid API supports four track sizing modes:

ModeSyntaxUse it for
Fixedcolumn(160.dp)Icons, avatars, anything pixel-perfect.
Fractionalcolumn(1.fr)Fluid columns that share leftover space.
Percentagecolumn(40.percent)Sidebars sized relative to the parent.
Intrinsiccolumn(IntrinsicSize.Max)Tracks that hug their widest child.

The most powerful of these is the new Fr unit. It works like CSS fr: after every fixed and intrinsic track has claimed its space, the remainder is divided among Fr tracks proportionally. So column(2.fr) next to column(1.fr) means the first track gets two-thirds of the leftover width.

Grid(
    config = {
        column(IntrinsicSize.Max) // sidebar — hugs its content
        column(2.fr)              // main — gets 2/3 of remaining width
        column(1.fr)              // aside — gets 1/3 of remaining width
        row(IntrinsicSize.Max)
        rowGap(16.dp)
        columnGap(16.dp)
    },
) {
    SidebarNav()
    ContentPane()
    DetailPane()
}

Step 4 — Real example: an adaptive product card screen

Here is a more realistic layout: a product detail screen with a hero image, a title block, a price/CTA card, and a specs panel. On a phone we want a single column; on a foldable we want hero + summary side-by-side; on a tablet we want a true 12-column layout with the specs panel pinned to the right.

The Grid API makes this almost embarrassingly clean. We compute the column configuration from a WindowSizeClass, then place children with explicit columnSpan and rowSpan via the new Modifier.grid() helper.

@Composable
fun ProductScreen(windowSize: WindowSizeClass) {
    val columns = when (windowSize.widthSizeClass) {
        WindowWidthSizeClass.Compact  -> 4
        WindowWidthSizeClass.Medium   -> 8
        WindowWidthSizeClass.Expanded -> 12
        else -> 4
    }

    Grid(
        config = {
            repeat(columns) { column(1.fr) }
            row(IntrinsicSize.Max) // hero
            row(IntrinsicSize.Max) // body
            rowGap(24.dp)
            columnGap(16.dp)
        },
    ) {
        // Hero image — always full width
        HeroImage(
            modifier = Modifier.grid(columnSpan = columns, rowSpan = 1)
        )

        // Title block — half width on medium+, full on compact
        TitleBlock(
            modifier = Modifier.grid(
                columnSpan = if (columns >= 8) columns / 2 else columns
            )
        )

        // Price + CTA — fills the other half on medium+
        PriceCard(
            modifier = Modifier.grid(
                columnSpan = if (columns >= 8) columns / 2 else columns
            )
        )

        // Specs panel — only pinned on expanded; otherwise full width below
        if (columns == 12) {
            SpecsPanel(modifier = Modifier.grid(columnSpan = 4, rowSpan = 2))
        } else {
            SpecsPanel(modifier = Modifier.grid(columnSpan = columns))
        }
    }
}

Two things to notice. First, columnSpan and rowSpan are declarative — there is no manual placement math, and Compose's auto-flow fills holes left to right, top to bottom. Second, switching layouts across breakpoints is just an if on the column count. The same composable tree handles every form factor, which is exactly what previous nested Row/Column setups could not do without painful branching.

Step 5 — Explicit placement with grid coordinates

Auto-flow covers most cases, but sometimes you need a child to land in a specific cell — for example, a 'New' badge in the top-right of a card. Modifier.grid() accepts columnStart and rowStart (1-indexed) for absolute placement:

Box(
    Modifier
        .grid(columnStart = columns, rowStart = 1, columnSpan = 1, rowSpan = 1)
        .background(Color.Red, RoundedCornerShape(50))
        .padding(horizontal = 8.dp, vertical = 2.dp)
) {
    Text("NEW", color = Color.White, style = MaterialTheme.typography.labelSmall)
}

Negative indices count from the end, so columnStart = -1 always lands in the rightmost column regardless of how many columns the breakpoint produced. This is the cleanest way to pin trailing content in an adaptive layout.

Step 6 — Reacting to posture and orientation

Grid is a regular composable, so all standard Compose state patterns apply. To react to a foldable's posture (book mode, tabletop, half-open), combine WindowSizeClass with the new WindowInfo.posture field shipped in the same April '26 release:

val posture = LocalWindowInfo.current.posture
val columns = when {
    posture is Posture.TableTop -> 6   // top half tilted, 6-col strip
    posture is Posture.Book     -> 8   // two-page book layout
    windowSize.widthSizeClass == WindowWidthSizeClass.Expanded -> 12
    windowSize.widthSizeClass == WindowWidthSizeClass.Medium   -> 8
    else -> 4
}

Step 7 — When to pick Grid vs LazyVerticalGrid

It is tempting to swap every LazyVerticalGrid for the new Grid, but they solve different problems. Grid is a non-lazy, structural container: it measures every child up front and pays for that with deterministic placement, intrinsic sizing, and full row/column span support. LazyVerticalGrid is a virtualized list that happens to render in two dimensions — it cannot do intrinsic sizing across rows, but it scales to thousands of items because it only composes what is on screen.

A simple rule of thumb: if your item count is fixed, known, and small (under roughly 200 cells), use Grid. If items come from a paged data source, a database query, or a network feed, stay with LazyVerticalGrid. The two compose well together — a top-level Grid for screen chrome with a LazyVerticalGrid embedded in one of its cells is a very common pattern in the new sample apps Google shipped alongside the 1.11 release.

Grid(
    config = {
        column(280.dp)   // pinned filter rail
        column(1.fr)     // scrollable product feed
        rowGap(0.dp)
        columnGap(16.dp)
    },
) {
    FilterRail()
    LazyVerticalGrid(                   // virtualized inside a Grid cell
        columns = GridCells.Adaptive(160.dp),
        contentPadding = PaddingValues(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        items(products) { ProductCell(it) }
    }
}

Common pitfalls

Three issues catch most teams porting from nested Row/Column layouts:

  • Do not use Grid for long lists. Grid measures every child eagerly. For scrollable feeds with hundreds of items, LazyVerticalGrid is still the right tool — it virtualizes off-screen items. Reach for Grid only when you have a bounded number of cells (think dashboards, forms, screen-level chrome).
  • Fr is not weight. Modifier.weight() divides the parent's remaining space among siblings; 1.fr divides the Grid's leftover track space among Fr tracks. They look similar but compose differently when intrinsic content forces a track to grow.
  • columnSpan must fit. If you ask for columnSpan = 8 on a 4-column grid, Compose 1.11 will throw IllegalArgumentException in debug builds and clamp silently in release. Always derive spans from the live column count.
  • Auto-flow honors source order. Reorder children in code to reorder them visually. There is no order equivalent yet — track this on the Compose issue tracker if you need it.

Quick reference

APIWhere it livesWhat it does
Grid { ... }foundation.layoutThe 2D layout container.
column(size)config blockAdds a column track.
row(size)config blockAdds a row track.
rowGap(dp) / columnGap(dp)config blockGutter between tracks.
1.frTrack sizeFractional unit, like CSS fr.
40.percentTrack sizePercent of Grid's available size.
IntrinsicSize.MaxTrack sizeHug the widest/tallest child.
Modifier.grid(...)Child modifierSpan and explicit placement.

Next steps

  • Replace one nested Row/Column screen in your app with Grid and measure the recomposition count in the Layout Inspector — fewer layout passes is the most common quick win.
  • Pair Grid with the new mediaQuery API (also April '26) for cleaner breakpoint logic than WindowSizeClass alone.
  • Read the official Grid documentation at developer.android.com/develop/ui/compose/layouts/adaptive/grid for the full API surface, including row templates and named tracks (in alpha).
  • Watch the Android Developers' April '26 "What's new in Compose" talk on YouTube for live demos of foldable posture handling.

Built one of these layouts? Star the matching example in the official androidx samples repo and tag #JetpackCompose when you ship — the Compose team is actively collecting feedback for the next release.

Comments

Subscribe to join the conversation...

Be the first to comment