Custom Modifiers with Modifier.Node
One-line definition: A custom modifier in modern Compose is built from three pieces — an extension function (API), a ModifierNodeElement (blueprint with equality), and a Modifier.Node (long-lived worker that hooks into draw, layout, or input). The Node is created on attach, updated in place when params change, and destroyed on detach — preserving identity across param changes and avoiding per-recomposition allocation.
1. Why it exists (the problem it solves)
Modifier.composed { ... } (the older API) has real issues:
- Allocates per call site, every recomposition — bad in lists.
- No proper lifecycle hooks — you hack with
DisposableEffect. - Can’t be skipped — Compose can’t tell when params didn’t change.
- State is fragile — manage with
remember, vulnerable to identity issues.
Modifier.Node (Compose 1.4+) solves all of this:
- One Node per attached modifier, mutated in place when params change.
- Real lifecycle (
onAttach/onDetach) and a Node-boundcoroutineScope. - Element equality drives skipping — no work when params didn’t change.
- Identity preserved across param updates: animations don’t restart, observers don’t re-subscribe, decoded resources survive.
The Compose team recommends all production custom modifiers be Modifier.Node-based. composed { } is fine for prototyping only.
2. The mental model
A
Modifier.Nodeis a long-lived tenant attached to a UI node, with its own lifecycle independent of recomposition.
Each Node hooks into one or more pipelines by implementing the matching interface:
| Pipeline | Interface | What you can do |
|---|---|---|
| Draw | DrawModifierNode |
Paint before/after the child |
| Layout | LayoutModifierNode |
Measure and place the child |
| Input | PointerInputModifierNode |
Handle gestures, pointer events |
| Composition | CompositionLocalConsumerModifierNode |
Read CompositionLocals via currentValueOf(...) |
| Coordinates | GlobalPositionAwareModifierNode |
Observe own position in window |
| Semantics | SemanticsModifierNode |
Provide a11y info |
A single Node can mix in several at once.
3. The three pieces every custom modifier has
┌────────────────────────────────┐
│ 1. Extension function (API) │ what users call
│ fun Modifier.foo(...) │
└─────────────┬──────────────────┘
│ returns
▼
┌────────────────────────────────┐
│ 2. Element (blueprint) │ data class, drives equality
│ ModifierNodeElement<MyNode> │
└─────────────┬──────────────────┘
│ creates / updates
▼
┌────────────────────────────────┐
│ 3. Node (running instance) │ attached to a UI node
│ Modifier.Node + interfaces │ does the actual work
└────────────────────────────────┘
- Extension function — pure sugar; just builds an Element via
this then MyElement(...). - Element —
data classwhose properties are exactly the modifier’s parameters. Compose uses itsequals/hashCodeto decide: same params → no work; different params → callupdate(node); first time → callcreate(). - Node — long-lived. Implements pipeline interfaces. Has lifecycle hooks (
onAttach/onDetach) and acoroutineScope.
Same shape for every custom modifier. Pick the right interface(s), fill in callbacks, done.
4. Lifecycle and invalidation
The four hooks
| Hook | Lives on | When | Use for |
|---|---|---|---|
create() |
Element | First time at this position | Build a fresh Node |
update(node) |
Element | Same Element type, params possibly changed | Mutate Node fields; decide what to restart |
onAttach() |
Node | Node is attached and live | Start coroutines, observe sources |
onDetach() |
Node | Node is being removed | Stop everything started in onAttach |
Order: create → onAttach → (update 0..N times) → onDetach. Node identity is stable across updates — that’s the whole point.
coroutineScope is a property of Modifier.Node, auto-cancelled on detach. Use it instead of any external scope.
Auto-invalidation vs manual
- Auto-invalidate: a
mutableStateOf/mutableFloatStateOffield, read insidedraw()/measure(), invalidates that phase when written. - Manual: for plain
varfields, callinvalidateDraw(),invalidateMeasurement(), orinvalidatePlacement()after change.
Rule of thumb: back any field whose change should redraw with mutableStateOf. Reach for manual invalidation only when state-backing isn’t an option.
5. Code examples
a. Stateless draw — Modifier.colorTint
// API
fun Modifier.colorTint(color: Color): Modifier =
this then ColorTintElement(color)
// Element — data class for automatic equals/hashCode.
private data class ColorTintElement(
val color: Color,
) : ModifierNodeElement<ColorTintNode>() {
override fun create() = ColorTintNode(color)
override fun update(node: ColorTintNode) { node.color = color }
}
// Node — implements DrawModifierNode to participate in the draw phase.
private class ColorTintNode(
var color: Color,
) : Modifier.Node(), DrawModifierNode {
override fun ContentDrawScope.draw() {
drawContent() // child draws first
drawRect(color = color) // tint on top
}
}
b. Stateful draw + animation — Modifier.shimmer
Demonstrates all four lifecycle hooks, coroutineScope, and state-backed auto-invalidation.
fun Modifier.shimmer(durationMillis: Int = 1200): Modifier =
this then ShimmerElement(durationMillis)
private data class ShimmerElement(
val durationMillis: Int,
) : ModifierNodeElement<ShimmerNode>() {
override fun create() = ShimmerNode(durationMillis)
override fun update(node: ShimmerNode) {
// Only restart if the period actually changed.
if (node.durationMillis != durationMillis) {
node.durationMillis = durationMillis
node.restart()
}
}
}
private class ShimmerNode(
var durationMillis: Int,
) : Modifier.Node(), DrawModifierNode {
// State-backed → writes auto-invalidate draw.
private var phase by mutableFloatStateOf(0f)
private var job: Job? = null
override fun onAttach() {
// Node is live; start work HERE (not in create()).
startAnimation()
}
override fun onDetach() {
// Symmetric cleanup. coroutineScope is also auto-cancelled.
job?.cancel()
job = null
}
fun restart() {
job?.cancel()
startAnimation()
}
private fun startAnimation() {
job = coroutineScope.launch {
while (isActive) {
animate(0f, 1f, animationSpec = tween(durationMillis, LinearEasing)) { v, _ ->
phase = v // state write → draw auto-invalidates
}
}
}
}
override fun ContentDrawScope.draw() {
drawContent()
val x = phase * size.width
drawRect(
brush = Brush.linearGradient(
colors = listOf(Color.Transparent, Color.White.copy(alpha = 0.3f), Color.Transparent),
start = Offset(x - 200f, 0f),
end = Offset(x, size.height),
),
blendMode = BlendMode.SrcAtop,
)
}
}
c. Layout modifier — Modifier.halfMaxSize
fun Modifier.halfMaxSize(): Modifier = this then HalfMaxSizeElement
// No params → singleton object, not data class.
private object HalfMaxSizeElement : ModifierNodeElement<HalfMaxSizeNode>() {
override fun create() = HalfMaxSizeNode()
override fun update(node: HalfMaxSizeNode) { /* nothing to update */ }
override fun hashCode() = 0
override fun equals(other: Any?) = other === this
}
private class HalfMaxSizeNode : Modifier.Node(), LayoutModifierNode {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints,
): MeasureResult {
// 1. Tighten constraints to half the max size.
val halved = constraints.copy(
maxWidth = constraints.maxWidth / 2,
maxHeight = constraints.maxHeight / 2,
)
// 2. Measure child.
val placeable = measurable.measure(halved)
// 3. Report size and place.
return layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
}
d. Real-world layout — Modifier.bleedHorizontal (escape LazyColumn padding)
Problem: LazyColumn(contentPadding = PaddingValues(horizontal = 16.dp)) shrinks every item’s constraints by 32.dp. A HorizontalDivider between items inherits that and looks indented. We want the divider edge-to-edge.
Technique: measure the child with expanded constraints, report the original size, place at negative X. The width carries the right edge — only x is needed in place().
Quick version (lambda-based, fine for prototyping)
fun Modifier.bleedHorizontal(padding: Dp): Modifier = layout { measurable, constraints ->
val extraPx = padding.roundToPx() * 2
// Measure the child with both min and max width expanded by 2 * padding.
// offset() adds to both ends — perfect for fillMaxWidth() children.
val placeable = measurable.measure(
constraints.offset(horizontal = extraPx)
)
// Report the ORIGINAL (padded) width so the LazyColumn item slot is undisturbed.
layout(placeable.width - extraPx, placeable.height) {
// Shift child left by `padding`; its width carries it `padding` past the right edge.
placeable.place(-padding.roundToPx(), 0)
}
}
Visualizing the placement:
parent (332) |================================|
padded slot (300) [............................] ← original constraints
child (332) [================================] ← measured wider
^ ^
-16 ← left bleed 316 ← right bleed (300 + 16)
Production version (Modifier.Node)
fun Modifier.bleedHorizontal(padding: Dp): Modifier =
this then BleedHorizontalElement(padding)
private data class BleedHorizontalElement(
val padding: Dp,
) : ModifierNodeElement<BleedHorizontalNode>() {
override fun create() = BleedHorizontalNode(padding)
override fun update(node: BleedHorizontalNode) {
if (node.padding != padding) {
node.padding = padding
node.invalidateMeasurement() // padding affects measure → re-run it
}
}
}
private class BleedHorizontalNode(
var padding: Dp,
) : Modifier.Node(), LayoutModifierNode {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints,
): MeasureResult {
val extraPx = padding.roundToPx() * 2
val placeable = measurable.measure(constraints.offset(horizontal = extraPx))
val width = constraints.constrainWidth(placeable.width - extraPx)
val height = constraints.constrainHeight(placeable.height)
return layout(width, height) {
placeable.place(-padding.roundToPx(), 0)
}
}
}
Constraints helpers worth knowing
constraints.offset(horizontal = px)— adds to bothminandmax. Right when the child usesfillMaxWidth().constraints.copy(maxWidth = ...)— grow only the upper bound. Right when the child should be allowed (not forced) to be wider.constraints.constrainWidth(value)/constrainHeight(value)— clamps a size into the original constraints. Defensive.
Gotchas
- Modifier order matters.
Modifier.bleedHorizontal(16.dp).background(Gray)✅ — bg fills the bled area. Reverse order ❌ — bg uses original (smaller) width. - Clipping ancestors break the bleed. A
CardorModifier.clip(...)somewhere up the chain will cut it off. LazyColumn doesn’t clip individual items, so it works there. - The simpler escape hatch: don’t apply padding via
LazyColumn(contentPadding = ...). Apply per item instead, and dividers won’t need this modifier:
Reach forLazyColumn { items(rows) { RowContent(it, modifier = Modifier.padding(horizontal = 16.dp)) } item { HorizontalDivider() } }bleedHorizontalonly when you can’t change the LazyColumn’scontentPadding.
e. Reading a CompositionLocal — Modifier.themedBorder
If a modifier needs to read from composition (a CompositionLocal, an ambient theme value), mix in CompositionLocalConsumerModifierNode and read with currentValueOf(...).
❌ Anti-pattern 1: plain extension
fun Modifier.themedBorder(width: Dp = 1.dp): Modifier {
val color = LocalContentColor.current // ❌ no composition scope here — won't compile
return this.border(width, color)
}
A plain extension runs outside composition. It cannot see CompositionLocals.
❌ Anti-pattern 2: wrap the child in a composable
@Composable
fun ThemedBordered(width: Dp = 1.dp, content: @Composable () -> Unit) {
val color = LocalContentColor.current
Box(modifier = Modifier.border(width, color)) { content() }
}
Compiles, but inserts a Box layout node, breaks the child’s identity on toggles, exact “single-child wrapper” smell from the previous lesson.
❌ Anti-pattern 3: Modifier.composed { }
fun Modifier.themedBorder(width: Dp = 1.dp): Modifier = composed {
val color = LocalContentColor.current
this.border(width, color)
}
Works, but legacy: allocates per call site every recomposition, can’t be skipped, no proper lifecycle. Discouraged for production.
✅ Correct: Modifier.Node + CompositionLocalConsumerModifierNode
fun Modifier.themedBorder(width: Dp = 1.dp): Modifier =
this then ThemedBorderElement(width)
private data class ThemedBorderElement(
val width: Dp,
) : ModifierNodeElement<ThemedBorderNode>() {
override fun create() = ThemedBorderNode(width)
override fun update(node: ThemedBorderNode) { node.width = width }
}
private class ThemedBorderNode(
var width: Dp,
) : Modifier.Node(),
DrawModifierNode,
CompositionLocalConsumerModifierNode {
override fun ContentDrawScope.draw() {
// currentValueOf is the consumer's API. Reads the value at this
// node's position in the tree, picks up changes from any ancestor.
val color = currentValueOf(LocalContentColor)
drawContent()
drawRect(color = color, style = Stroke(width = width.toPx()))
}
}
Comparison
| Approach | Compiles? | Identity safe? | Allocates per recomp? | Reads CompositionLocal? | Ship-ready? |
|---|---|---|---|---|---|
| Plain extension | ❌ | — | — | ❌ | ❌ |
| Wrapper composable | ✅ | ❌ | yes (extra Box) | ✅ | ❌ |
composed { } |
✅ | ✅ | yes | ✅ | ⚠️ legacy |
Modifier.Node + CompositionLocalConsumerModifierNode |
✅ | ✅ | no | ✅ | ✅ |
6. Key APIs
Modifier.then(other)— append; the basis of all chaining.ModifierNodeElement<N>— abstract class to extend;create()andupdate(node)are the two methods you implement.Modifier.Node— the long-lived tenant;onAttach(),onDetach(),coroutineScopeavailable.- Pipeline interfaces:
DrawModifierNode,LayoutModifierNode,PointerInputModifierNode,CompositionLocalConsumerModifierNode,GlobalPositionAwareModifierNode,SemanticsModifierNode. - Invalidation:
invalidateDraw(),invalidateMeasurement(),invalidatePlacement(). - State backing:
mutableStateOf,mutableFloatStateOf,mutableIntStateOf. Read inside the relevant phase to get auto-invalidation. - Constraints helpers:
Constraints.offset(horizontal, vertical),Constraints.copy(...),constraints.constrainWidth(x),constraints.constrainHeight(y). - For CompositionLocal-reading nodes:
currentValueOf(SomeCompositionLocal).
7. Common mistakes
- Forgetting
data classon the Element. Without correct equality, every recomposition triggersupdate()(or recreation). Allocation churn. - Starting work in
create()instead ofonAttach.create()runs before the node is attached. - Forgetting
onDetachcleanup → leaked coroutines / observers. - Plain
varfor animated values → no auto-invalidation. Either back with state or callinvalidateDraw()manually. - Using
LaunchedEffectthinking inside a Node — wrong, you’re not in composition. UsecoroutineScope.launch. - Using your own
CoroutineScope(...)— leaks on detach. Use the Node’scoroutineScope. - Building a new Node inside
update()— defeats the pattern.update()mutates the existing Node. - Reaching for
Modifier.composed { }for new code — legacy.Modifier.Nodeis the modern path. - Using
offset()when you wanted to grow onlymaxWidth.offset()adds to both ends; if the child should be allowed (not forced) to be wider, usecopy(maxWidth = ...). - Reading
LocalContentColor.currentinside a plainModifierextension — won’t compile; useCompositionLocalConsumerModifierNode.
8. Best practices
- Default to
Modifier.Node. Usecomposed { }only for one-off scratch work. - Make Element a
data classwhose properties are exactly the modifier’s parameters. - Back any field whose change should redraw with
mutableStateOf— auto-invalidation is cleaner than manual. - Cancel in
onDetachwhatever you started inonAttach. Symmetric. - One effect per modifier. Don’t pack four behaviors into one Node.
- Naming convention:
FooElementandFooNodefor the private types;Modifier.foo()for the public API. Lowercase verb/adjective. - Apply layout-affecting modifiers (size, padding, bleed) before decorations like
background/borderin the chain — order is application order.
9. Related concepts / what to learn next
Already covered:
- Component identity (
compose-component-identity.md). - Modifier vs single-child wrappers (
compose-modifiers-vs-single-child-wrappers.md).
Next sessions:
PointerInputModifierNode— the input pipeline; gestures, custom touch handling.GlobalPositionAwareModifierNodeandLayoutAwareModifierNode— observing position and size.DelegatingNode— composing existing modifier nodes inside your own (the reuse pattern).SubcomposeLayout— when a parent must measure something then compose something else based on the result.LookaheadScope— pre-measurement for shared-element transitions and predictive layout.- Intrinsic measurements — how
min/maxIntrinsicWidth/Heightflow through modifiers and layouts.
10. Quick recall
- A custom modifier is three pieces: extension function →
ModifierNodeElement→Modifier.Node. - The Element is a
data class— itsequals/hashCodedecidescreatevsupdatevs no-op. - The Node is long-lived, attached to a UI node, mutated in place by
update(). Identity is stable across param changes. - Pick pipeline interface(s) for the Node:
DrawModifierNode,LayoutModifierNode,PointerInputModifierNode,CompositionLocalConsumerModifierNode, etc. A Node can mix multiple. - Lifecycle:
create→onAttach→ (update0..N times) →onDetach. Start inonAttach, clean up inonDetach. UsecoroutineScope. - Auto-invalidate: state-backed fields read inside
draw/measure. Manual:invalidateDraw()/invalidateMeasurement()/invalidatePlacement(). - For padded LazyColumn dividers: measure the child with
constraints.offset(horizontal = extraPx), report the original width, place at negative X. Width carries the right side automatically. - To read a CompositionLocal from a modifier, mix in
CompositionLocalConsumerModifierNodeand usecurrentValueOf(LocalFoo)— never readLocalFoo.currentfrom a plain extension. Modifier.composed { }is legacy;Modifier.Nodeis the modern path for any custom modifier in production.- A plain
Modifierextension can’t read composition; a wrapper composable can but breaks identity;Modifier.Nodedoes both well.