Compose 9 min read

Compose 1.12: MeshGradient, a Properly Controllable SwipeToReveal, and Scroll Indicators Across Everything

Compose Foundation 1.12.0-beta02 dropped on July 1, 2026, alongside Compose UI 1.12.0-beta02 and Material3 1.5.0-alpha23. Looking at the release notes in aggregate, three things stand out as genuinely useful in production — not "interesting, file for later" but "I can improve an existing screen with this today." There's also a handful of Material3 component APIs graduating to stable that unblock teams still holding off on expressive components for that reason.

This is a different post from my Compose 1.11 write-up, which covered pausable composition and the new Grid API. Both of those were runtime and layout changes that mostly affect how you build screens. The 1.12 additions are more surface-level — they're about how screens look and behave — which means quicker wins with less architectural consideration required.

1.12
beta02 — July 1, 2026
New
MeshGradient + Scroll Indicators
Stable
TopAppBar, ButtonGroup in M3

1. MeshGradient — Organic Gradients Without the Custom Drawing Code

If you've ever wanted a gradient that doesn't look like it came from a 2015 design template, MeshGradient is the answer. It's a gradient system where you define a grid of control points, each with its own color, and the renderer smoothly interpolates between them. The result is those rich, organic, multi-directional color flows you see on iOS (where SwiftUI has had this since iOS 18) and in modern design-forward apps.

The API arrived as Modifier.meshGradient() in alpha02 and was paired with MeshGradientPainter in alpha03 for use as a background or a painted asset. Here's the basic shape:

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ProfileHeader() {
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(220.dp)
            .meshGradient(
                width = 3,
                height = 3,
                points = listOf(
                    // row 0
                    Offset(0f, 0f), Offset(0.5f, 0f), Offset(1f, 0f),
                    // row 1
                    Offset(0f, 0.5f), Offset(0.5f, 0.5f), Offset(1f, 0.5f),
                    // row 2
                    Offset(0f, 1f), Offset(0.5f, 1f), Offset(1f, 1f)
                ),
                colors = listOf(
                    Color(0xFF0D1B2A), Color(0xFF1A2744), Color(0xFF0D2137),
                    Color(0xFF1B3A6B), Color(0xFF22C55E), Color(0xFF164E63),
                    Color(0xFF0A0F1E), Color(0xFF0D2137), Color(0xFF050505)
                )
            )
    ) {
        // header content
    }
}

Width and height define the control point grid (3×3 here gives 9 points), points are normalized 0..1 coordinates, and colors match 1:1. The renderer handles the Bezier interpolation between those points — you get a smooth, non-linear blend across the entire surface without writing a single line of Canvas code.

Where this actually earns its keep

The obvious use is hero sections and onboarding screens — anything that currently uses a static LinearGradient background that looks fine but unremarkable. For Nodat, the project detail page uses a solid card background because a proper gradient background was too much manual canvas work. MeshGradient makes this trivially achievable.

The less obvious use: animated mesh gradients. The control point coordinates and colors are regular Compose state, so you can animate them with animateFloatAsState or Animatable. The ambient, breathing gradient effects you see in premium audio apps — think Spotify's color-extracted backgrounds — are now much closer to feasible in pure Compose.

API status: MeshGradient is still experimental, gated behind @OptIn(ExperimentalComposeUiApi::class). The shape is stable enough for side projects and internal screens today, but "experimental" in Compose has historically meant the signature can shift before finalization. Worth using; just don't bet a major production feature on this specific API surface yet.

2. SwipeToReveal Gets Real Programmatic Control

SwipeToReveal shipped in an earlier Compose Foundation release and it works — until you need to do anything beyond the default fling-and-snap behavior. The common complaint from developers building Gmail-style list interactions: there's no way to trigger a reveal programmatically (for a tutorial hint, or to open the item from a keyboard shortcut), no way to configure fling velocity thresholds, and no way to know the current drag position to animate something else in sync.

1.12.0-beta02 addresses all three. The additions:

val revealState = rememberRevealState()

// programmatic reveal for an accessibility action
LaunchedEffect(shouldHintSwipe) {
    if (shouldHintSwipe) {
        revealState.drag(targetValue = RevealValue.FullyRevealed)
    }
}

SwipeToReveal(
    state = revealState,
    flingBehavior = rememberSnapFlingBehavior(
        snapLayoutInfoProvider = remember(revealState) {
            RevealSnapLayoutInfoProvider(revealState, minVelocityThreshold = 800.dp)
        }
    ),
    action = {
        DeleteAction(
            modifier = Modifier.swipeToRevealDragScope(revealState)
        )
    }
) {
    ListItem(headlineContent = { Text("Swipe me") })
}

The thing I was actually blocked on before this: building a "swipe to archive" pattern in Nodat's note list where the archive button scales from 0 to full size as the swipe progresses. With SwipeToRevealDragScope exposing the offset, you can now drive that scale transform directly from the gesture rather than having to approximate it from state snapshots.

The flingBehavior gap this closes

Default SwipeToReveal has an opinionated snap threshold — fast enough fling means the item snaps fully open, below that threshold it snaps back. This is correct for Gmail-style "delete on release," but it's wrong for "peek then snap" interactions where you want the item to reveal a bit, wait for deliberate confirmation, then act. Custom flingBehavior lets you implement that second pattern without forking the composable.

3. Scroll Indicator: A Consistent API Across Every Scrollable Container

Before 1.12, if you wanted a custom scroll indicator (not the default thin platform scrollbar, but something on-brand — a dot indicator, a progress track, a custom-styled bar), you were writing it from scratch by reading scroll state and positioning something yourself. Workable, but tedious, especially when you need to keep it consistent across LazyColumn, LazyGrid, Pager, and regular ScrollState containers.

The new Modifier.scrollIndicator() API standardizes this with a ScrollIndicatorFactory interface. You implement the factory once, and it works across all of these container types:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PostFeed(posts: List) {
    val listState = rememberLazyListState()

    LazyColumn(
        state = listState,
        modifier = Modifier
            .fillMaxSize()
            .scrollIndicator(
                state = listState,
                factory = DotScrollIndicatorFactory(
                    activeColor = Color(0xFF22C55E),
                    inactiveColor = Color(0xFF22C55E).copy(alpha = 0.25f)
                )
            )
    ) {
        items(posts) { post ->
            PostCard(post)
        }
    }
}

ScrollIndicatorFactory takes the ScrollIndicatorState from whichever container type you're using and gives you the current thumb fraction and thumb offset to position your custom indicator however you like. The framework handles the mapping from scroll position to normalized position — you just draw.

This is the kind of API that sounds small until you consider how many apps maintain three or four slightly-different hand-rolled scroll indicator implementations across different screen types. A single factory that covers all container types is a meaningful deduplication — especially if you're maintaining a design system component library across multiple apps.

4. Grid Named Areas — The 1.11 Grid API Gets More Expressive

If you read my Compose 1.11 post, you know the new Grid API lets you define CSS Grid-style track sizes and place items with row/column spans. The 1.12 addition is a higher-level placement mechanism: named areas.

Instead of placing items with numeric row/column span values that require you to mentally track grid coordinates, you define semantic regions in the grid configuration and place items into those regions by name:

@OptIn(ExperimentalGridApi::class)
@Composable
fun DashboardLayout() {
    Grid(
        config = {
            repeat(4) { column(1.fr) }
            repeat(3) { row(1.fr) }
            gap(12.dp)

            val header = area(row = 0, column = 0, rowSpan = 1, columnSpan = 4)
            val featured = area(row = 1, column = 0, rowSpan = 2, columnSpan = 2)
            val sidebar = area(row = 1, column = 2, rowSpan = 2, columnSpan = 2)
        }
    ) {
        HeaderCard(modifier = Modifier.gridItem(header))
        FeaturedPostCard(modifier = Modifier.gridItem(featured))
        SidebarStack(modifier = Modifier.gridItem(sidebar))
    }
}

The difference in readability is real. With numeric spans you need to mentally reconstruct the grid every time you touch the layout. With named areas, Modifier.gridItem(featured) says exactly what it does — this composable fills the featured slot — and any change to what "featured" means happens in one place in the config block, not scattered across every item's modifier.

This is the pattern CSS Grid developers have relied on for years with grid-template-areas. Getting it in Compose 1.12 makes complex, semantically structured layouts meaningfully easier to maintain, especially on foldable and tablet targets where you're rebuilding the same content into different regional arrangements.

5. Material3 Expressive: TopAppBar and ButtonGroup Hit Stable

A fair number of teams have been hesitant to adopt Material3 Expressive components precisely because of the experimental annotation — and the memory of previous Compose API churn makes that caution understandable. The 1.5.0-alpha22 and alpha23 releases made significant progress toward unblocking those teams.

What graduated to non-experimental in this cycle:

The new Expressive TimePicker (alpha23) is still in active development, but worth noting: it uses the Material Expressive design language rather than the older wheel/dial pattern, and it introduces interaction sounds via the new SoundEffectOnInteraction composable from Compose UI 1.12.

// TopAppBarScrollBehavior is now stable — no OptIn needed
@Composable
fun ArticleScreen(scrollBehavior: TopAppBarScrollBehavior) {
    Scaffold(
        topBar = {
            MediumFlexibleTopAppBar(
                title = { Text("Android Engineering") },
                scrollBehavior = scrollBehavior
            )
        }
    ) { paddingValues ->
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .nestedScroll(scrollBehavior.nestedScrollConnection)
        ) { /* content */ }
    }
}

Note on Material3 versioning: These stabilizations are in Material3 1.5.0-alpha23, which is not a stable release of the library. "Graduated to non-experimental" means the annotations were removed from those specific APIs — not that the library version is stable. You're still taking on alpha-channel risk for other components in the library.

Should You Move to 1.12 Now?

The calculus here is different for different features. MeshGradient and Scroll Indicators are experimental but have low coupling to the rest of your architecture — you can adopt them on a single screen, see how they feel in production, and pull them out without touching anything else if the API changes before final stabilization. Worth experimenting with now.

SwipeToReveal improvements are additive — if you're already using SwipeToReveal, the new RevealState.drag() and custom flingBehavior are strictly more capable than what you had. Upgrade your BOM and use them.

Grid Named Areas requires you to be on the Grid API from 1.11 already. If you're not there yet, I'd still hold off on load-bearing production use until the API stabilizes further. But if you're already using Grid experimentally, named areas are an improvement worth adopting.

Material3 component stabilizations are the clearest call: if you were waiting for TopAppBar or ButtonGroup to lose the experimental annotation before shipping them, that blocker is gone.

The broader pattern here: 1.12 is filling in the UI toolkit gaps that have frustrated Compose developers without requiring you to rethink how your app is structured. You don't need to migrate architecture to get better gradients. The boring, quiet releases are often the most useful ones.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment