Building Custom Modifiers
You've chained built-in modifiers together dozens of times by now —
Modifier.padding(16.dp).clip(RoundedCornerShape(8.dp)).background(Color.White). Once
you notice the exact same chain of two or three modifiers repeated across several RecipeBox
screens, it's a sign you should extract it into your own Modifier
extension function — the modifier equivalent of pulling a repeated composable out into its own
function. This is a smaller, more mechanical skill than custom layouts or CompositionLocal, but
it does require understanding Modifier.then() and, for the case where your modifier
needs its own memory, composed {}.
A Modifier Is Just a Chain
Every Modifier chain is really a linked sequence of individual modifier elements,
each wrapping the one before it. Modifier.then(other) is the function that appends
one modifier onto another — it's what the dot-chaining syntax compiles down to. Writing your own
modifier function almost always ends by returning this.then(...) so it composes
correctly with whatever the caller chains before or after it.
The Simple Case: a Plain Modifier Extension Function
If your custom modifier only combines existing modifiers with fixed or parameterized values — no
new remembered state of its own — a plain extension function is all you need. Here's
Modifier.cardShadow(), wrapping RecipeBox's repeated shape + elevation + background
combination used on every recipe card:
CardShadowModifier.kt
fun Modifier.cardShadow(
cornerRadius: Dp = 12.dp,
elevation: Dp = 4.dp
): Modifier = this.then(
Modifier
.shadow(elevation, shape = RoundedCornerShape(cornerRadius))
.clip(RoundedCornerShape(cornerRadius))
.background(Color.White)
)
Every RecipeCard-style composable across the app now reaches for one call instead of repeating the same three-line chain:
@Composable
fun RecipeRow(recipe: Recipe) {
Row(modifier = Modifier.cardShadow().padding(16.dp)) {
Text(recipe.title)
}
}
Because cardShadow() returns this.then(...) rather than a brand new
Modifier, it correctly preserves anything the caller already chained before it —
Modifier.fillMaxWidth().cardShadow() keeps the width behavior intact.
When a Modifier Needs Its Own State: composed {}
A plain extension function like cardShadow() works because it has no internal state
— it's just wiring together other modifiers using the parameters it was given. Some custom
modifiers need remember internally — an animation's current value, a gesture's
drag offset. For that you wrap the modifier's body in Modifier.composed {}, which
gives you a composable scope (so remember and other composable functions are legal
to call) scoped correctly per-usage of the modifier:
PressScaleModifier.kt — a modifier that remembers its own animated scale
fun Modifier.pressScale(pressedScale: Float = 0.96f): Modifier = composed {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (isPressed) pressedScale else 1f,
label = "pressScale"
)
this
.graphicsLayer { scaleX = scale; scaleY = scale }
.clickable(interactionSource = interactionSource, indication = null) {}
}
Every recipe card that applies Modifier.pressScale() gets its own
independent remembered interactionSource and animated scale — because
composed {} re-runs the factory lambda separately for each place the modifier is
actually attached, rather than sharing one instance across every usage.
Order Still Matters
A custom modifier doesn't change the rule you already know from built-in modifiers: order in the
chain changes behavior. Modifier.padding(16.dp).cardShadow() draws the shadow inside
the padding; Modifier.cardShadow().padding(16.dp) draws the shadow at the outer
edge, then adds padding to the content within it. Test your custom modifier in the same
"chain position" your real usages will place it in.
Keep Custom Modifiers Small and Named for Intent
The best custom modifiers read like a sentence describing intent —
Modifier.cardShadow(), Modifier.pressScale() — rather than bundling
unrelated concerns into one do-everything modifier. If you find a custom modifier growing many
unrelated parameters, it's usually a sign it should be split into two smaller, separately named
modifiers that get chained together at the call site instead.