← All articles

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:

Modifier.Node (Compose 1.4+) solves all of this:

The Compose team recommends all production custom modifiers be Modifier.Node-based. composed { } is fine for prototyping only.


2. The mental model

A Modifier.Node is 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
└────────────────────────────────┘

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: createonAttach → (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

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

Gotchas

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


7. Common mistakes

  1. Forgetting data class on the Element. Without correct equality, every recomposition triggers update() (or recreation). Allocation churn.
  2. Starting work in create() instead of onAttach. create() runs before the node is attached.
  3. Forgetting onDetach cleanup → leaked coroutines / observers.
  4. Plain var for animated values → no auto-invalidation. Either back with state or call invalidateDraw() manually.
  5. Using LaunchedEffect thinking inside a Node — wrong, you’re not in composition. Use coroutineScope.launch.
  6. Using your own CoroutineScope(...) — leaks on detach. Use the Node’s coroutineScope.
  7. Building a new Node inside update() — defeats the pattern. update() mutates the existing Node.
  8. Reaching for Modifier.composed { } for new code — legacy. Modifier.Node is the modern path.
  9. Using offset() when you wanted to grow only maxWidth. offset() adds to both ends; if the child should be allowed (not forced) to be wider, use copy(maxWidth = ...).
  10. Reading LocalContentColor.current inside a plain Modifier extension — won’t compile; use CompositionLocalConsumerModifierNode.

8. Best practices


Already covered:

Next sessions:


10. Quick recall