Introducing the Experimental Styles API in Jetpack Compose
Introducing the Experimental Styles API in Jetpack Compose
Jetpack Compose's Modifier system has been the primary way to apply visual properties to composables. You chain modifiers like background(), padding(), and border() to build up the appearance and behavior of UI elements. While powerful, this approach has limitations when dealing with interactive states. When you want a button to change color when pressed, you need to manually track state, create animated values, and conditionally apply different modifiers. The new experimental Styles API aims to solve this by providing a declarative way to define state-dependent styling with automatic animations.
In this article, you'll explore how the Styles API works, examining how Style objects encapsulate visual properties as composable lambdas, how StyleScope provides access to layout, drawing, and text properties, how StyleState exposes interaction states like pressed, hovered, and focused, how the system automatically animates between style states without manual Animatable management, and how the two-node modifier architecture efficiently applies styles while minimizing invalidation. This isn't a guide on basic Compose styling; it's an exploration of a new paradigm for defining interactive, stateful UI appearances.
The problem with stateful styling
Consider implementing a button that changes color when hovered and pressed. With the current Modifier approach, you need to manage this manually:
@Composable
fun InteractiveButton(onClick: () -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
val backgroundColor by animateColorAsState(
targetValue = when {
isPressed -> Color.Red
isHovered -> Color.Yellow
else -> Color.Green
}
)
Box(
modifier = Modifier
.clickable(interactionSource = interactionSource, indication = null) { onClick() }
.background(backgroundColor)
.size(150.dp)
)
}
This pattern requires several pieces: an InteractionSource to track interactions, state derivations for each interaction type, animated values for smooth transitions, and conditional logic to determine the current appearance. The code is verbose and the concerns are scattered across multiple declarations.
The Styles API consolidates this into a single declarative definition:
@Composable
fun InteractiveButton(onClick: () -> Unit) {
ClickableStyleableBox(
onClick = onClick,
style = {
background(Color.Green)
size(150.dp)
hovered { animate { background(Color.Yellow) } }
pressed { animate { background(Color.Red) } }
}
)
}
The style block declares both the default appearance and how it changes in different states. The animate wrapper tells the system to smoothly transition when entering or leaving that state. No manual state tracking, no explicit animated values, no conditional modifier chains.
Style: A functional interface for visual properties
The Style interface is the foundation of the API. It's defined as a functional interface that operates on a StyleScope:
@ExperimentalFoundationStyleApi
public fun interface Style {
public fun StyleScope.applyStyle()
}
This design means you can create styles using lambda syntax. When you write style = { background(Color.Green) }, you're creating a Style instance whose applyStyle function calls background(Color.Green) on the receiving StyleScope.
Styles can be combined using the then infix function or the Style() factory functions:
val baseStyle = Style {
background(Color.White)
contentPadding(16.dp)
}
val borderedStyle = Style {
borderWidth(1.dp)
borderColor(Color.Gray)
}
val combinedStyle = baseStyle then borderedStyle
When styles are combined, properties from later styles override earlier ones on a per-property basis. This differs from Modifier chaining, where both modifiers apply and order determines visual stacking. With Styles, if both baseStyle and borderedStyle set background, only the second value is used.
The combination is implemented through CombinedStyle, an internal class that holds an array of styles and applies them sequentially:
internal class CombinedStyle(val styles: Array<Style>) : Style {
override fun StyleScope.applyStyle() {
styles.fastForEach { with(it) { applyStyle() } }
}
}
StyleScope: The property surface
StyleScope is a sealed interface that provides all the properties you can set within a style. It extends CompositionLocalAccessorScope, giving styles access to theme values, and Density, enabling dp-to-pixel conversions:
@ExperimentalFoundationStyleApi
public sealed interface StyleScope : CompositionLocalAccessorScope, Density {
public val state: StyleState
// ... property functions
}
The state property is crucial as it provides access to the current interaction state, enabling conditional styling based on whether the element is pressed, hovered, focused, or other states.
The scope provides functions across several categories. Layout properties control dimensions and spacing, including width(), height(), size() for explicit dimensions, fillWidth(), fillHeight(), fillSize() for fractional sizing, minWidth(), maxWidth(), minHeight(), maxHeight() for constraints, and contentPadding() and externalPadding() for spacing inside and outside the content.
Drawing properties control visual appearance: background(Color) and background(Brush) for fills, borderWidth(), borderColor(), and border() for outlines, shape() for corner rounding and custom shapes, and dropShadow() and innerShadow() for elevation effects.
Transform properties control spatial modifications: alpha() for opacity, scaleX(), scaleY(), and scale() for sizing transforms, translationX(), translationY(), and translation() for position offsets, rotationX(), rotationY(), and rotationZ() for 3D rotation, and clip() and zIndex() for clipping and layering.
Text properties control typography within the styled element: textStyle() for complete text styling, fontSize(), fontWeight(), fontFamily() for font properties, contentColor() and contentBrush() for text color, and letterSpacing(), lineHeight(), textAlign() for text layout.
StyleState: Interaction awareness
The StyleState interface exposes the current interaction state of the styled element:
public sealed interface StyleState {
public val isEnabled: Boolean
public val isFocused: Boolean
public val isHovered: Boolean
public val isPressed: Boolean
public val isSelected: Boolean
public val isChecked: Boolean
public val triStateToggle: ToggleableState
public operator fun <T> get(key: StyleStateKey<T>): T
}
These properties are read during style resolution. When you write conditional logic based on state.isPressed, the style system tracks this dependency and re-resolves the style when the pressed state changes.
The StyleScope provides convenience functions for common state patterns:
style = {
background(Color.Green)
hovered {
background(Color.Yellow)
}
pressed {
background(Color.Red)
}
focused {
borderWidth(2.dp)
borderColor(Color.Blue)
}
}
These are syntactic sugar for if (state.isHovered) { ... } patterns, but they also enable the animation system to understand state transitions.
Automatic animations
One of the most powerful features of the Styles API is declarative animations. Instead of manually creating Animatable instances and launching coroutines, you wrap style changes in animate:
style = {
background(Color.Blue)
size(150.dp)
hovered {
animate {
background(Color.Yellow)
scale(1.1f)
}
}
pressed {
animate(tween(100)) {
background(Color.Red)
scale(0.95f)
}
}
}
The animate function accepts optional AnimationSpec parameters for customizing the transition. When the element enters the hovered state, the system automatically animates from the current background color to yellow and from the current scale to 1.1f. When it leaves the hovered state, it animates back.
The animation system is managed by StyleAnimations, an internal class that tracks active animations:
internal class StyleAnimations {
private val entries = mutableObjectListOf<Entry>()
private class Entry(
val key: Any,
var style: ResolvedStyle,
val toSpec: AnimationSpec<Float>,
val fromSpec: AnimationSpec<Float>,
val animatable: Animatable<Float, AnimationVector1D>,
var state: State,
)
}
Each animated style block gets an Entry that tracks its current animation progress. The withAnimations function applies interpolated values to the resolved style using linear interpolation based on each animation's current value.
The system handles several complexity points automatically: concurrent animations when multiple state changes overlap, interruption when a new state change occurs mid-animation, and entry/exit animations when styles are added or removed from the composition.
ResolvedStyle: The runtime representation
When a style is applied, it's resolved into a ResolvedStyle instance that holds concrete values for all properties:
internal class ResolvedStyle : StyleScope, InspectableValue {
// Layout properties
var contentPaddingStart: Dp = Dp.Unspecified
var contentPaddingEnd: Dp = Dp.Unspecified
var width: Dp = Dp.Unspecified
var height: Dp = Dp.Unspecified
// ... approximately 50 properties
// Optimization flags
private var layoutFlags: Int = 0
private var drawFlags: Int = 0
private var textFlags: Int = 0
}
The class uses a bitset-based flagging system to track which properties have been set. This optimization serves two purposes: it allows the system to distinguish between "not set" and "set to a default value", and it enables efficient change detection by comparing flag integers rather than individual properties.
Text-related enum values are packed into a single integer field using bit-shifting:
private var textEnums: Int = 0
// Packed: fontWeight | fontStyle | fontSynthesis | textDecoration |
// textAlign | textDirection | hyphens | lineBreak
This reduces the memory footprint of each ResolvedStyle instance while maintaining fast access to individual values through bit masking operations.
The two-node modifier architecture
Styles are applied to elements through the styleable modifier extension:
public fun Modifier.styleable(
styleState: StyleState?,
style: Style,
): Modifier
The implementation uses two modifier nodes: an outer node and an inner node. As the implementation notes: "Two LayoutModifierNodes are required. The outer modifier implements almost everything, except for padding. In order for padding, drawing, etc. to work properly, we need this inner modifier to add the padding."
The StyleOuterNode handles layout constraints, measurements, background drawing, transforms, shadows, and most style application. The StyleInnerNode specifically handles content padding, which must be applied after the outer modifications for correct layout behavior.
The outer node implements multiple node interfaces:
internal class StyleOuterNode :
LayoutModifierNode,
DrawModifierNode,
CompositionLocalConsumerModifierNode,
ObserverModifierNode,
TraversableNode
This multi-interface implementation allows a single node to participate in layout, drawing, composition local observation, and tree traversal, minimizing the number of nodes in the modifier chain.
Selective invalidation
The style system carefully tracks which subsystems need invalidation when properties change. The ResolvedStyle maintains separate flags for layout, draw, and text changes:
internal fun invalidate(previous: ResolvedStyle): Int {
var result = 0
if (layoutChanged(previous)) result = result or LAYOUT_INVALIDATION
if (drawChanged(previous)) result = result or DRAW_INVALIDATION
if (textChanged(previous)) result = result or TEXT_INVALIDATION
return result
}
When a style only changes drawing properties like background or alpha, only the draw phase is invalidated. Layout and composition remain untouched. This granular invalidation is similar to how Compose's phase system works, where changes to graphicsLayer properties only trigger drawing without recomposition or relayout.
The animation system also returns invalidation flags after applying animated values, ensuring that only the necessary phases run during each animation frame.
Composition local access
Because StyleScope extends CompositionLocalAccessorScope, styles can read composition locals directly:
style = {
val colors = LocalColors.current
background(colors.surface)
contentColor(colors.onSurface)
pressed {
background(colors.surfaceVariant)
}
}
This integration means styles can be theme-aware without requiring explicit color parameters. When the theme changes, styles that read theme values are automatically re-resolved.
The observation is handled by ObserverModifierNode, which tracks composition local reads during style resolution and invalidates the node when those values change.
Putting it together
The Styles API represents new APIs in how Compose handles interactive styling. By combining state observation, automatic animations, and selective invalidation into a single declarative API, it reduces boilerplate while maintaining performance.
A complete example demonstrates the API's expressiveness:
@Composable
fun StyledCard(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val cardStyle = Style {
background(MaterialTheme.colorScheme.surface)
shape(RoundedCornerShape(12.dp))
contentPadding(16.dp)
dropShadow(4.dp, Color.Black.copy(alpha = 0.1f))
hovered {
animate(tween(200)) {
dropShadow(8.dp, Color.Black.copy(alpha = 0.15f))
translationY((-2).dp)
}
}
pressed {
animate(tween(100)) {
dropShadow(2.dp, Color.Black.copy(alpha = 0.05f))
scale(0.98f)
}
}
focused {
borderWidth(2.dp)
borderColor(MaterialTheme.colorScheme.primary)
}
}
ClickableStyleableBox(
onClick = onClick,
modifier = modifier,
style = cardStyle
) {
Text(title)
}
}
This single style definition handles default appearance, hover effects with shadow and position changes, press effects with scale animation, and focus indication with a border. All transitions are automatic, state tracking is implicit, and the system ensures minimal invalidation during updates.
Conclusion
The experimental Styles API introduces a new paradigm for defining interactive, stateful UI appearances in Jetpack Compose. Rather than manually orchestrating InteractionSource, state derivations, and animated values across scattered declarations, the API consolidates styling logic into cohesive, declarative blocks.
The Style functional interface encapsulates visual properties as composable lambdas that operate on StyleScope. This scope provides comprehensive access to layout properties like dimensions and padding, drawing properties like backgrounds and borders, transform properties like scale and rotation, and text properties for typography control. The StyleState interface exposes interaction states including pressed, hovered, focused, selected, and checked, enabling conditional styling without explicit state management.
The animation system handles transitions automatically. Wrapping style changes in animate blocks tells the system to smoothly interpolate between states, with optional AnimationSpec parameters for customization. The StyleAnimations class manages entry tracking, interpolation, and concurrent animation handling internally.
Under the hood, the two-node modifier architecture separates outer modifications (layout, drawing, transforms) from inner modifications (content padding) to ensure correct behavior. The ResolvedStyle class stores approximately 50 properties with bitset-based optimization for memory efficiency and change detection. Selective invalidation ensures that changes to drawing properties only trigger the draw phase, while layout changes trigger layout and drawing but skip composition.
The Styles API is currently experimental and subject to change, marked with @ExperimentalFoundationStyleApi. However, it demonstrates a compelling direction for Compose: consolidating interactive styling into declarative definitions that the framework can optimize automatically. As the API matures, it may fundamentally change how we approach stateful UI styling in Compose applications.
As always, happy coding!
— Jaewoong

