Compose is growing a styles API, and it's for the gap theming never covered

MaterialTheme scopes values to a subtree, which is the wrong shape for reusable per-component configuration. Compose's styles direction addresses that gap; wrapper composables are the pattern to use today.

5 min read
androidcomposekotlinmaterial3

Day 49 — Compose is growing a styles API

Day 49 of 100, closing the theming pillar. Five days of MaterialTheme leave one gap that keeps getting filled by hand in every codebase.

The symptom

A "danger button" that exists nine times:

Button(
    onClick = ::delete,
    colors = ButtonDefaults.buttonColors(
        containerColor = MaterialTheme.colorScheme.error,
        contentColor = MaterialTheme.colorScheme.onError,
    ),
    shape = MaterialTheme.shapes.small,
    contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp),
) { Text("Delete") }

Nine call sites, nine copies of five parameters. The theme can't hold this — it's not a colour, a type style or a shape; it's a combination of component parameters, and MaterialTheme has no slot for that.

Why the obvious fix fails

The obvious fix is a CompositionLocal, since that's the theming mechanism you know:

val LocalDangerButtonColors = staticCompositionLocalOf { … }

It doesn't fit, and the reason is instructive. A CompositionLocal scopes a value to a subtree — everything below gets it. But "danger button" isn't a property of a region of the screen; it's a property of this button, sitting next to a normal one. Providing it around a single component means wrapping every call site in a provider, which is more code than the parameters were.

Day 15's three tests say the same thing: it isn't ambient, it has no sensible default, and nobody would override it for a subtree. Wrong tool.

Themes scope values to a subtree; styles bundle parameters for one component

The actual mechanism

The gap is real and worth naming precisely. There are two different kinds of reusable styling:

Theme values — ambient, subtree-scoped, apply to everything below. Colour scheme, typography, shapes, spacing. CompositionLocal is exactly right.

Component styles — a named bundle of one component's parameters, applied per call. "Danger button", "compact list item", "hero card". CompositionLocal is exactly wrong.

Compose's styling direction is about the second: letting a component declare a style type that packages its own parameters, so a design system can name variants without wrapping each one in a bespoke composable. The View system had this as XML styles; Compose deliberately started without it, and the gap has been filled by convention ever since.

Until the API lands broadly, the convention that works is a wrapper composable:

@Composable
fun DangerButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    content: @Composable RowScope.() -> Unit,
) {
    Button(
        onClick = onClick,
        modifier = modifier,
        enabled = enabled,
        colors = ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.error,
            contentColor = MaterialTheme.colorScheme.onError,
        ),
        shape = MaterialTheme.shapes.small,
        contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp),
        content = content,
    )
}

Nine call sites become DangerButton(onClick = ::delete) { Text("Delete") }.

Getting the wrapper right

Most wrappers in real codebases have one of three flaws, and all three are avoidable.

Forwarding the modifier. Take modifier: Modifier = Modifier and pass it through as the first thing on the wrapped component — Day 19's convention. A wrapper that swallows the caller's modifier is a component nobody can position.

Forwarding the parameters that vary. enabled belongs in the signature; colors does not, because fixing the colours is the entire point. The test: would two call sites reasonably differ on this? If yes, it's a parameter; if no, it's part of the style.

Not over-forwarding. Copying all fourteen of Button's parameters into the wrapper recreates the problem it solved. Expose what varies, fix what doesn't, and add a parameter later if a real second case appears.

The defaults-object pattern

For a component with several variants, an object of parameter bundles keeps them together and discoverable, mirroring how Material's own *Defaults objects work:

object AppButtonDefaults {
    @Composable
    fun dangerColors() = ButtonDefaults.buttonColors(
        containerColor = MaterialTheme.colorScheme.error,
        contentColor = MaterialTheme.colorScheme.onError,
    )

    @Composable
    fun successColors() = ButtonDefaults.buttonColors(
        containerColor = AppTheme.semantic.success,
        contentColor = AppTheme.semantic.onSuccess,
    )
}

Button(onClick = ::delete, colors = AppButtonDefaults.dangerColors()) { Text("Delete") }

This sits between raw parameters and a full wrapper. It's the right choice when only one aspect varies — colours here — and the rest of the call is normal. When three or more parameters travel together, a wrapper reads better.

It has one advantage a wrapper doesn't: the call site still reads as a Button, so nothing is hidden. A reader who wants to know what DangerButton does has to open it; a reader looking at Button(colors = AppButtonDefaults.dangerColors()) already knows it's an ordinary button with different colours. For a single-aspect variant that transparency is worth more than the brevity.

Where the styling story is heading

The direction of travel across recent Compose releases is worth knowing even where the APIs aren't final:

  • Parameter bundles over ambient values for anything component-specific, which is the distinction this post is about.
  • *Defaults objects as the discoverable surface — every Material component has one, and they're the intended extension point rather than copying literals.
  • Theme overlays for regions, not for components: MaterialTheme(colorScheme = …) around a subtree remains the sanctioned way to restyle an area.

The stable advice underneath all of it hasn't changed: put ambient values in the theme, put component configuration in a named wrapper or defaults object, and don't reach for a CompositionLocal to configure one component.

How to prove it

The duplication is greppable, and the count is usually surprising:

grep -rn "ButtonDefaults.buttonColors(" --include="*.kt" app/src/main | wc -l

Every hit outside your design-system package is a call site that will drift. The same search with CardDefaults, TextFieldDefaults and IconButtonDefaults finds the rest.

A useful follow-up: group the hits by the parameters they set. Nine calls setting the same five parameters is one wrapper. Nine calls each setting something different is not duplication at all, and wrapping them would be premature.

For the wrapper itself, the test is whether it's previewable in isolation:

@Preview
@Composable fun DangerButtonPreview() = AppTheme {
    DangerButton(onClick = {}) { Text("Delete") }
}

If that needs setup beyond the theme, the wrapper is reading something it should have taken as a parameter.

What this generalizes to

The closing point of the pillar: scope is a design decision, not an implementation detail. A value that belongs to a region and a value that belongs to a component need different mechanisms, and using one for the other produces either provider-wrapping at every call site or a theme that accumulates component-specific fields.

Five days of theming reduce to one question asked repeatedly — what is the scope of this decision? Colour scheme: the app. A promo banner's palette: a subtree. A danger button's colours: one component. Three answers, three mechanisms, and picking correctly is most of what makes a design system pleasant to use.

The View system answered all three with XML styles and a style attribute, which is why it could express "danger button" easily and "restyle this subtree" only with theme overlays that were notoriously fiddly. Compose inverted the strengths. Knowing which system you're fighting is usually the fastest way to find the idiomatic answer.

Tomorrow, Day 50 opens the text and typography pillar at the halfway mark of the series.


Day 49 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Styles in Compose.