跪拜 Guibai
← Back to the summary

Jetpack Compose's New Styles API: A Declarative Alternative to Modifier Chains

Getting Started with the Compose Styles API

Jetpack Compose recently introduced a brand new Styles API for customizing the styles of UI elements and components.

In the recent past (well, I have to admit that API updates are too fast now), such things usually relied on Modifier or parameters like padding, color on components; the Styles API consolidates these styles into a unified Style parameter or Modifier.styleable.

This article will first organize its basic concepts and usage. There will probably be two or three more articles to continue learning this API, and we will also discuss the specific usage of the Styles API together.

Note: The Styles API is currently in the @Experimental stage and may change in future versions. Material Design style support will also be provided in future versions.

Why is the Styles API needed?

Compose already has Modifier, so why introduce a Style?

I believe I'm not the only one who thought this when first seeing this API!

After studying it in depth and applying it for a while, my own view is simply: Modifier is still responsible for many things, but it may not be suitable for carrying all "style configurations".

When a component has a large number of color, spacing, shape, font, and state style parameters, these configurations are scattered across parameters, Modifier, and state judgments, and the component API becomes increasingly bloated.

Think about it, if you make a complex control with many customizable style parts, what would you do?

The traditional Modifier usage is more suitable for controlling the outermost control; as for the customizable parts inside the control, we generally don't continue passing Modifier, but expose them through function parameters.

What Styles wants to solve is to centralize such appearance rules, making components more easily driven by themes, design systems, and interaction states.

If we want to expand on this issue, it actually involves the responsibility boundary of Modifier, component API design, state styles, and design system reuse. We won't expand on this today.

Let's finish looking at the basic concepts and usage of the Styles API, and then we'll come back to discuss "where exactly should it be placed" and "how should it be used".

To save myself some typing, I will use Styles or Style to refer to the Styles API from now on.

Core Concepts

Styles mainly solves several types of problems:

Styles is not meant to replace Modifier, but is more suitable for replacing style parameters (like padding, colors). For interactions, custom drawing, property stacking, precise event control, etc., Modifier is still needed.

Google also thoughtfully provides an agent skill to help developers use the new Styles API in their applications.

Before we start, let's briefly look at some concepts:

Concept Description
Style An interface defining the appearance of a UI element, similar to CSS styles. Can be customized locally or configured uniformly through a theme. When the same property is set repeatedly, the latter overrides the former.
StyleScope The receiver scope inside Style, providing property definition functions like background(), padding(), border(), and access to the current StyleState.
StyleState Tracks the interaction state of an element (e.g., isEnabled, isPressed, isChecked), supports custom states, and is used to implement conditional styles.

Of course, you don't need to memorize these concepts. Let's look at the code!

Getting Started

To use these APIs, you need the latest Compose foundation alpha version.

Set the Compose version to the alpha version shown in the official documentation examples in libs.versions.toml or app/build.gradle.kts; use the latest alpha version in practice.

compose = "1.12.0-alpha03"
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "compose" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "compose" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "compose" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "compose" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "compose" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "compose" }

Property Overview

The supported style properties cover a wide range, but not all Modifier functionality can be replaced by styles. Here is the complete property classification:

Group Properties Inheritable by Child Components
Layout & Size
Padding contentPadding (internal), externalPadding (external), supports directional/horizontal/vertical/all directions No
Size fillWidth/fillHeight/fillSize(), width, height, size (supports Dp, DpSize, Float fractions) No
Positioning left/top/right/bottom offsets No
Visual Appearance
Fill background, foreground (supports Color or Brush) No
Border borderWidth, borderColor, borderBrush No
Shape shape (clip and border will use this shape) No
Shadow dropShadow, innerShadow No
Transform
Spatial Movement translationX, translationY, scaleX/scaleY, rotationX/rotationY/rotationZ No
Control alpha, zIndex (stacking order), transformOrigin (pivot point) No
Typography
Style textStyle, fontSize, fontWeight, fontStyle, fontFamily Yes
Coloring contentColor, contentBrush (also used for icon styles) Yes
Paragraph lineHeight, letterSpacing, textAlign, textDirection, lineBreak, hyphens Yes
Decoration textDecoration, textIndent, baselineShift Yes

Typography-related properties support inheritance — when set on a parent component, they propagate to all child Text components.

You can understand it this way: anything related to text supports inheritance.

How similar this is to CSS!

Using Styles

There are roughly three ways to use Styles!

1. Directly Using the Style Parameter

Components that expose a Style parameter can directly set style properties within the lambda:

BaseButton(
    onClick = { },
    style = { }
) {
    BaseText("Click me")
}

Inside the style lambda, you can set various properties, such as contentPadding or background:

BaseButton(
    onClick = {},
    style = { background(Color(0xFF1976D2)) }
) {
    Text("Blue", color = Color.White)
}

BaseButton(
    onClick = {},
    style = {
        background(Color(0xFF388E3C))
        contentPadding(horizontal = 24.dp, vertical = 12.dp)
    },
) {
    Text("Green (padded)", color = Color.White)
}

1.png

2. Applying Styles via Modifier.styleable

For components without a built-in style parameter, you can use Modifier.styleable:

Row(
    modifier = Modifier.styleable { }
) {
    BaseText("Content")
}

Similar to the style parameter, you can include properties like background or shape inside the lambda:

Row(
    modifier = Modifier
        .styleable {
            background(Color(0xFFE3F2FD))
            shape(RoundedCornerShape(12.dp))
            contentPadding(16.dp)
        }
        .fillMaxWidth(),
) {
    Text("styled via Modifier.styleable")
}

2.png

When multiple Modifier.styleable calls are chained, non-inherited properties act on the current component like multiple Modifiers; inherited properties are determined by the last styleable in the chain.

When using Modifier.styleable, you may also need to create and provide a StyleState to apply state-based styles.

3. Defining Reusable Standalone Styles

Extract styles into variables that can be shared across multiple components:

val style = Style { background(Color.Blue) }

// Used via the style parameter
BaseButton(onClick = { }, style = style) {
    BaseText("Button")
}

// Used via Modifier.styleable (requires StyleState)
val styleState = remember { MutableStyleState(null) }
Column(Modifier.styleable(styleState, style)) {
    BaseText("Column content")
}

You can also pass the same style to multiple components:

val sharedStyle = Style {
    background(Color(0xFF6A1B9A))
    shape(RoundedCornerShape(8.dp))
    contentPadding(horizontal = 16.dp, vertical = 10.dp)
}

BaseButton(onClick = {}, style = sharedStyle) {
    Text("Shared on button", color = Color.White)
}

val columnState = remember { MutableStyleState(null) }
Column(
    modifier = Modifier
        .styleable(columnState, sharedStyle)
        .fillMaxWidth(),
) {
    Text("Same Style on a Column", color = Color.White)
}

3.png

Property Override and Merge Rules

Style properties are not additive; the last setting takes precedence. This is different from the behavior of Modifier. You can add multiple style properties by setting different properties on each line:

BaseButton(
    onClick = { },
    style = {
        background(Color.Blue)
        contentPaddingStart(16.dp)
    }
) {
    BaseText("Button")
}

When the same property is set repeatedly, the latter overrides the former:

val overrideStyle = Style {
    background(Color(0xFFD32F2F))
    background(Color(0xFF008080)) // final background
    contentPadding(64.dp)
    contentPaddingTop(16.dp)
}
BaseButton(onClick = {}, style = overrideStyle) {
    Text("Override", color = Color.White)
}

4.png

Notice? contentPaddingTop changes the top padding to 16.dp, while other directions still use the setting from contentPadding(64.dp).

Multiple Style objects can be merged using then, with the latter overriding the former:

val style1 = Style { background(TealColor) }
val style2 = Style { contentPaddingTop(16.dp) }

BaseButton(
    style = style1 then style2,
    onClick = { },
) {
    BaseText("Click me!")
}

When multiple styles specify the same property, the last set property is selected. Because properties in a style are not additive, the last padding passed in will override the horizontal padding set by the initial contentPadding, and the last background color will also override the background color set by the initial style:

val s1 = Style {
    background(Color(0xFFD32F2F))
    contentPadding(32.dp)
}
val s2 = Style {
    contentPaddingHorizontal(8.dp)
    background(Color(0xFFBDBDBD))
}
BaseButton(onClick = {}, style = s1 then s2) {
    Text("Follow RockByte's public account", color = Color.Black)
}

4_5.png

Style Inheritance

Typography and coloring related properties support downward inheritance from parent components. The override priority from high to low is:

Priority Method Example
1 (Highest) Component direct parameter Text(color = Color.Red)
2 Style parameter Text(style = Style { contentColor(Color.Red) })
3 Modifier.styleable Modifier.styleable { contentColor(Color.Red) }
4 (Lowest) Parent component inheritance Typography/color properties set by the parent component

Properties like contentColor set by the parent component automatically propagate to all child Text components. Child components can also override inherited values through their own styles. Below is an example of a parent component setting text properties:

val styleState = remember { MutableStyleState(null) }
Column(
    modifier = Modifier.styleable(styleState) {
        background(Color.LightGray)
        val blue = Color(0xFF4285F4)
        val purple = Color(0xFFA250EA)
        val colors = listOf(blue, purple)
        contentBrush(Brush.linearGradient(colors))
    },
) {
    BaseText("Children inherit", style = { width(60.dp) })
    BaseText("certain properties")
    BaseText("from their parents")
}

Child components can also override inherited values from the parent through their own styles:

Column(
    modifier = Modifier.styleable {
        val blue = Color(0xFF4285F4)
        val purple = Color(0xFFA250EA)
        val colors = listOf(blue, purple)
        background(Brush.linearGradient(colors))
        contentPadding(32.dp)
    },
) {
    Box(
        modifier = Modifier.styleable(
            style = Style {
                background(Brush.linearGradient(listOf(Color.Red, Color.Blue)))
            },
        ),
    ) {
        BasicText("Children can override properties")
    }
    BasicText("set by their parents")
}

6.png

Encapsulating Style Functions and CompositionLocal

You can encapsulate common style combinations through extension functions on StyleScope:

fun StyleScope.outlinedBackground(color: Color) {
    border(2.dp, color)
    background(color.copy(alpha = 0.4f))
}

val customStyle = Style {
    outlinedBackground(Color.Red)
    contentPadding(horizontal = 20.dp, vertical = 12.dp)
    shape(RoundedCornerShape(8.dp))
}
BaseButton(onClick = {}, style = customStyle) {
    Text("outlinedBackground(Color.Red)", color = Color.White)
}

7.png

I have to mention one thing I really like about Styles: when I have already set shape, I don't need to pass shape separately to border and background. This isn't as straightforward in the Modifier call chain.

Styles also support reading design tokens from CompositionLocal:

val buttonStyle = Style {
    contentPadding(12.dp)
    shape(RoundedCornerShape(50))
    background(Brush.verticalGradient(LocalCustomColors.currentValue.background))
}

Handling Interaction States

The Styles API has built-in support for common interaction states: Pressed, Hovered, Focused, Selected, Enabled, Toggled.

StyleState is a stable read-only interface used to track whether an element is currently enabled, pressed, focused, etc.; within StyleScope, you can declare conditional styles based on these states.

You can directly declare styles for each state within the style block:

val lightBlue = Color(0xFFBBDEFB)
val lightRed = Color(0xFFFFCDD2)

val interactiveStyle = Style {
    background(Color.White)
    border(1.dp, Color(0xFF9E9E9E))
    contentPadding(12.dp)
    focused {
        background(lightBlue)
    }

    pressed {
        background(lightRed)
        border(2.dp, Color(0xFFD32F2F))
    }
}
BaseButton(
    onClick = {},
    style = interactiveStyle,
) {
    Text(
        "Hover / Focus / Press me",
        style = TextStyle(color = Color.Black, fontSize = 16.sp),
    )
}

8.gif

States can also be nested and combined, for example, handling hover + press scenarios:

hovered {
    background(lightPurple)
    pressed {
        background(lightOrange)  // Pressed while hovering
    }
}
pressed {
    background(lightRed)         // Pressed without hovering
}

Supporting States in Custom Components

When creating a custom styleable component, you need to connect the interactionSource to the styleState and pass the same interactionSource to the relevant interaction Modifiers, such as clickable or focusable:

@Composable
private fun GradientButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    style: Style = Style,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    content: @Composable RowScope.() -> Unit,
) {
    val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
    val styleState = rememberUpdatedStyleState(interactionSource) {
        it.isEnabled = enabled
    }
    Row(
        modifier = modifier
            .clickable(
                onClick = onClick,
                enabled = enabled,
                interactionSource = interactionSource,
                indication = null,
            )
            .styleable(styleState, baseGradientButtonStyle then style),
        content = content,
    )
}

Based on this foundational component, you can easily create derived components with interactive effects:

@Composable
fun LoginButton() {
    val loginButtonStyle = Style {
        pressed {
            background(Brush.linearGradient(listOf(Color.Magenta, Color.Red)))
        }
    }
    GradientButton(onClick = { }, style = loginButtonStyle) {
        BaseText("Login")
    }
}

Style Animations

Style changes during state transitions support built-in animations. Simply wrap the properties with animate inside the state block.

Moreover, animate also supports custom animationSpec, such as tween and spring animations:

val animatingStyle = Style {
    externalPadding(48.dp)
    border(3.dp, Color.Black)
    background(Color.White)
    size(100.dp)
    transformOrigin(TransformOrigin.Center)
    pressed {
        animate(tween(durationMillis = 400)) { // Tween animation
            borderColor(Color.Magenta)
            background(Color(0xFFB39DDB))
        }
        animate(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) { // Spring animation
            scale(1.2f)
        }
    }
}

val interactionSource = remember { MutableInteractionSource() }
val styleState = remember(interactionSource) { MutableStyleState(interactionSource) }
Box(
    modifier = Modifier
        .fillMaxWidth()
        .height(200.dp),
    contentAlignment = Alignment.Center,
) {
    Box(
        modifier = Modifier
            .clickable(
                interactionSource = interactionSource,
                indication = null,
                onClick = {},
            )
            .styleable(styleState, animatingStyle),
    )
}

9.gif

Custom State Styles

In addition to built-in interaction states, you can also define custom states. Taking a media player as an example, you can apply different styles based on the playback state (Stopped / Playing / Paused).

1. Define State Enum and Key

enum class PlayerState {
    Stopped,
    Playing,
    Paused
}

val playerStateKey = StyleStateKey(PlayerState.Stopped)

2. Create Extension Functions

var MutableStyleState.playerState
    get() = this[playerStateKey]
    set(value) { this[playerStateKey] = value }

fun StyleScope.playerPlaying(block: () -> Unit) {
    state(playerStateKey, block) { _, state -> state[playerStateKey] == PlayerState.Playing }
}

fun StyleScope.playerPaused(block: () -> Unit) {
    state(playerStateKey, block) { _, state -> state[playerStateKey] == PlayerState.Paused }
}

3. Use in Components

Define styleState in the composable and set styleState.playerState to the passed state. Pass styleState to the styleable function of the modifier:

@OptIn(ExperimentalFoundationStyleApi::class)
@Composable
private fun MediaPlayer(
    url: String,
    modifier: Modifier = Modifier,
    style: Style = Style,
    state: PlayerState = remember { PlayerState.Paused },
) {
    val styleState = remember { MutableStyleState(null) }
    styleState.playerState = state
    Box(
        modifier = modifier
            .fillMaxWidth()
            .height(80.dp)
            .styleable(styleState, style),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            text = "MediaPlayer(${state.name}): $url",
            style = TextStyle(fontSize = 16.sp),
        )
    }
}

This is just a fake MediaPlayer for testing purposes.

4. Define State Styles

Inside the style lambda, use the previously defined extension functions to apply state-based styles for custom states:

@Composable
fun StyleStateKeySample() {
    val style = Style {
        borderColor(Color.Gray)
        playerPlaying {
            animate { borderColor(Color.Green) }
        }
        playerPaused {
            animate { borderColor(Color.Blue) }
        }
    }
    // Modify the state parameter to change the style, or connect to a ViewModel to dynamically switch states
    MediaPlayer(
        url = "https://example.com/media/video",
        style = style,
        state = PlayerState.Stopped,
    )
}

10.gif

A Few Thoughts

The Styles API is still an experimental API, and Material support for Styles is also in future versions. When adopting it, you can start with custom design systems or a small number of custom components. It is not recommended to directly migrate all existing Modifier usage.

Currently, it seems suitable for solving three types of problems: too many component style parameters, scattered interaction state styles, and the need to reuse a set of style rules across a design system. At the same time, Modifier still handles interactions, custom drawing, and some behaviors that cannot be expressed by Style.

My own strong feeling is that the Styles API reinforces the concept of "state representing UI". In the past, many styles were written in the Modifier call chain, where the order of calls affected the final result. Reading it felt somewhat like imperative programming and didn't feel like a true DSL.

The new Style syntax puts styles, states, and style changes under states into the same declarative structure. It feels more like a DSL and is more suitable for describing what a UI should look like in different states. Although it is still very early, I have a feeling that this way of organizing styles around states might be a trend in the future evolution of Compose.

To Be Continued