Write a custom modifier only after you've ruled out a function
Most custom modifiers should be plain extension functions returning a chain. Modifier.Node is for elements that need their own state or draw/measure participation, and it replaced composed { } for good reasons.

Day 22 of 100. Days 19 and 20 covered using modifiers. Today: writing them, and the more useful skill of recognising when you shouldn't.
The symptom
The same six-line chain in fourteen files:
Modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface)
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp))
.padding(16.dp)
Change the corner radius and you change it fourteen times, and miss two.
Why the obvious fix fails
The obvious fix is to reach for composed { }, because that's what most search results
from a few years ago show:
fun Modifier.card() = composed {
val scheme = MaterialTheme.colorScheme
clip(RoundedCornerShape(12.dp))
.background(scheme.surface)
.border(1.dp, scheme.outline, RoundedCornerShape(12.dp))
.padding(16.dp)
}
This works and it is the wrong tool twice over.
composed { } is deprecated. It defeats modifier reuse: a composed modifier can't
be hoisted into a constant and shared, because its factory has to run inside composition
for each usage. It allocates on every recomposition, and it blocks the compiler
optimisation that would otherwise let the chain be compared by equality.
But the deeper problem is that this modifier doesn't need to be a modifier at all.

The actual mechanism: three options, in order of preference
1. A plain extension function. If your modifier is a fixed chain of existing
modifiers, it is a function. No composed, no node, no ceremony:
fun Modifier.card(shape: Shape = RoundedCornerShape(12.dp)) = this
.clip(shape)
.background(CardSurface)
.border(1.dp, CardOutline, shape)
.padding(16.dp)
this at the start is the important part — it chains onto the caller's modifier rather
than discarding it. Note this version takes its colours as constants; if it needs
MaterialTheme, see option 2.
2. A composable-reading value, passed in. When the chain needs MaterialTheme or
another CompositionLocal, the temptation is composed { }. Take a parameter instead:
fun Modifier.card(colors: CardColors, shape: Shape = RoundedCornerShape(12.dp)) = this
.clip(shape)
.background(colors.surface)
…
// at the call site, where composition context already exists
Box(Modifier.card(CardDefaults.colors()))
The modifier stays a pure function, stays hoistable, and the theme read happens where it always should — in the composable.
3. Modifier.Node. For elements that genuinely need to participate in draw, layout
or pointer input, or hold their own state across recompositions.
// The public API — a pure, comparable data holder
private data class ShakeElement(val offset: Float) : ModifierNodeElement<ShakeNode>() {
override fun create() = ShakeNode(offset)
override fun update(node: ShakeNode) { node.offset = offset }
}
private class ShakeNode(var offset: Float) : DrawModifierNode, Modifier.Node() {
override fun ContentDrawScope.draw() {
translate(left = offset) { this@draw.drawContent() }
}
}
fun Modifier.shake(offset: Float) = this then ShakeElement(offset)
Three pieces, each with one job. The Element is a data class so Compose can compare
old and new by equality — unchanged means no work. create() runs once when the node
attaches; update() runs when the element changes, mutating the existing node in place
rather than allocating a new one. The node itself is a long-lived object with the same
lifecycle as the layout node it's attached to.
That in-place update is the entire performance argument over composed { }: one
allocation for the element, zero for the node, and a cheap equality check to skip both.
Which node interface
You mix in the capability you need, and only that:
| Interface | For |
|---|---|
DrawModifierNode |
drawing before/after/around content |
LayoutModifierNode |
changing measurement or placement |
PointerInputModifierNode |
gestures |
SemanticsModifierNode |
accessibility and test properties |
GlobalPositionAwareModifierNode |
reacting to position in the window |
DelegatingNode |
composing several of the above |
A node implementing LayoutModifierNode overrides measure, receives the incoming
constraints, and returns a placement — the same Constraints-in, size-out contract from
Day 18, at the modifier level rather than the container level:
private class MinWidthNode(var min: Dp) : LayoutModifierNode, Modifier.Node() {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints,
): MeasureResult {
val minPx = min.roundToPx()
val p = measurable.measure(constraints.copy(minWidth = maxOf(constraints.minWidth, minPx)))
return layout(p.width, p.height) { p.placeRelative(0, 0) }
}
}
When it should be a composable instead
The honest test: does it emit UI, or decorate something that does?
A modifier decorates. If you find yourself wanting to add a child — a badge, a label, an icon — you want a composable, and probably a slot:
// Not a modifier: this emits an element
@Composable
fun Badged(badge: @Composable () -> Unit, content: @Composable () -> Unit) {
Box { content(); Box(Modifier.align(Alignment.TopEnd)) { badge() } }
}
Trying to express that as a modifier leads to drawWithContent re-implementations of
text layout, which is a long road to a worse result.
How to prove it
The reuse property is the easy check, and it's the one composed { } fails:
private val CardModifier = Modifier.card() // compiles only if `card()` is pure
@Test fun stable() {
assertEquals(Modifier.shake(1f), Modifier.shake(1f)) // data class equality
}
If your modifier can be hoisted into a top-level val, it's a pure function and Compose
can skip work when it hasn't changed. If it can't, you've either used composed { } or
read composition state inside it — both worth fixing.
For nodes, the allocation claim is checkable with the Layout Inspector's recomposition
counts plus a log in create() versus update(). create should fire once per attach;
update should fire only when the parameters change. If create fires repeatedly, the
element isn't comparing equal — usually because it holds a lambda that's freshly
allocated each composition.
What this generalizes to
The pattern is separating the description from the instance. ShakeElement is a
cheap, comparable value describing what you want; ShakeNode is the expensive
long-lived thing that does it. The framework diffs the descriptions and mutates the
instances.
That is the same split as @Composable functions versus the composition, and as React
elements versus fibers. Once you see it, create/update stops looking like
boilerplate and starts looking like the two halves of a diff.
A checklist before you write one
Four questions, in order. The first "yes" is your answer.
- Is it a fixed chain of existing modifiers? → extension function.
- Does it need theme or other composition values? → extension function taking those values as parameters.
- Does it emit or wrap a child element? → composable with a slot, not a modifier.
- Does it need to draw, measure, handle pointers, or hold state across
recompositions? →
Modifier.Node.
In a typical app codebase the counts come out roughly ten of the first, two of the
second, a few of the third, and zero or one of the fourth. Modifier.Node is a real
tool with a real purpose, and it is not the common case — worth saying because the
amount written about it implies otherwise.
The reason the ordering matters is that each step down costs something permanent. An extension function is readable by anyone; a node requires a reader to know three types and their lifecycle. Paying that once for a genuine drawing or measurement need is fine. Paying it for a chain of four built-in modifiers is a cost with no matching benefit, and it is the most common way custom-modifier code goes wrong.
Tomorrow, Day 23: LazyColumn — what "lazy" actually buys you, and the key parameter
whose absence causes bugs that look like anything but a missing key.
Day 22 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Custom modifiers.