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
@Experimentalstage 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:
- Simplify state styles: Define styles based on states like
hover,focus,pressedin a declarative way, significantly reducing boilerplate code; - Built-in animation support: Style transition animations during state changes work out of the box, avoiding recomposition issues caused by
animateColorAsState; - Cleaner component API: Replace a large number of style parameters with a single
Styleparameter, making the interface clearer; - Better performance: Styles execute during the Draw and Layout phases, skipping the Composition phase, reducing recomposition;
- Standardization: Provide a set of unified style properties, making it easier for components to integrate into a style system.
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)
}
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")
}
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)
}
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)
}
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)
}
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")
}
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)
}
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),
)
}
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),
)
}
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,
)
}
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.