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.

Where this fits: Custom layouts, CompositionLocal, slot APIs, and custom modifiers are the toolbox behind almost every reusable Compose library you'll ever use, including Material 3 itself. You won't write these often, but recognizing the patterns will make reading library source code far less intimidating.
Next up: With composition patterns covered, it's time to give RecipeBox a real, persistent data layer. Room Database with Compose wires up Room and observes it from a composable UI.