A Painter is the thing that knows how big it wants to be
Painter draws content and declares an intrinsic size, which is what lets it participate in layout the way a resource does. Writing one is the right answer when drawing needs to be reusable across Image, Icon and modifiers.

Day 57 of 100. Between "load an asset" and "draw on a canvas" sits a small abstraction that most people skip past, and it's the one that makes custom drawing reusable.
The symptom
A placeholder that has to be re-implemented everywhere it's used:
// In the avatar
Box(Modifier.size(48.dp).background(Color.LightGray, CircleShape))
// In the feed thumbnail
Box(Modifier.fillMaxWidth().aspectRatio(16f/9f).background(Color.LightGray))
// In the AsyncImage — which wants a Painter, not a composable
AsyncImage(model = url, placeholder = ???)
The first two are composables, so they can't be passed where a Painter is expected. You
end up with two implementations of the same visual, and the AsyncImage one falls back to
a static drawable that doesn't match.
Why the obvious fix fails
The obvious fix is a shared composable plus a drawable resource:
@Composable fun Placeholder(modifier: Modifier) { … }
// and R.drawable.placeholder for the AsyncImage case
Two sources of truth for one visual, and they drift the first time the design changes. The drawable also can't adapt — a gradient placeholder that should follow the theme is a static PNG that doesn't.
What's needed is something that draws and can be handed to APIs expecting an asset.

The actual mechanism
Painter is an abstract class with two members that matter:
class ShimmerPainter(
private val color: Color,
private val progress: Float,
) : Painter() {
override val intrinsicSize: Size = Size.Unspecified // "size me from the layout"
override fun DrawScope.onDraw() {
drawRect(
brush = Brush.linearGradient(
colors = listOf(color, color.copy(alpha = 0.4f), color),
start = Offset(size.width * (progress - 0.3f), 0f),
end = Offset(size.width * progress, 0f),
)
)
}
}
intrinsicSize is what a Painter wants to be if nothing constrains it. A bitmap
returns its pixel dimensions; a vector returns its viewport. Size.Unspecified means "I
have no opinion — the layout decides", which is right for anything that fills its box.
That property is the whole reason Painter exists rather than a plain draw lambda. It's
what lets Image(painter, …) size itself from the asset, and what makes Day 55's
contentScale meaningful — there's a source size to scale from.
onDraw runs inside a DrawScope, with size already set to whatever the layout
allocated. Same scope as Modifier.drawBehind and Canvas, so everything you know from
one applies to the others.
Now the placeholder works in all three places:
val placeholder = ShimmerPainter(surfaceVariant, progress)
Image(painter = placeholder, contentDescription = null, modifier = Modifier.size(48.dp))
AsyncImage(model = url, placeholder = placeholder, contentDescription = null)
Box(Modifier.fillMaxWidth().aspectRatio(16f/9f).paint(placeholder))
Modifier.paint is the third consumer — it draws a Painter as a background, which is
how you use one without an Image.
The optional members
Two more overrides, both worth knowing:
override fun applyAlpha(alpha: Float): Boolean { … }
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { … }
Returning true means "I handled it"; false means the framework applies the effect over
your drawing instead. Implementing them lets a painter respond to Image(alpha = …) and
Icon(tint = …) natively, which is more efficient than a layer applied afterwards.
Most custom painters return false for both and let the framework handle it. Implement
them when the effect needs to apply per-element rather than to the composite — tinting
only the foreground of a two-part drawing, for instance.
Making it animate
A Painter is a plain object, so animating one means recomposing with new parameters:
@Composable
fun rememberShimmerPainter(color: Color): Painter {
val transition = rememberInfiniteTransition(label = "shimmer")
val progress by transition.animateFloat(
initialValue = 0f,
targetValue = 1.3f,
animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing)),
label = "progress",
)
return remember(color, progress) { ShimmerPainter(color, progress) }
}
That allocates a painter per frame, which is acceptable for a small object and worth
avoiding for a large one. The alternative is a mutable painter holding a
mutableFloatStateOf that onDraw reads — the draw-phase read from Day 10, so the
animation never invalidates composition at all:
class ShimmerPainter(private val color: Color) : Painter() {
var progress by mutableFloatStateOf(0f)
override val intrinsicSize = Size.Unspecified
override fun DrawScope.onDraw() { /* reads progress here, in the draw phase */ }
}
For anything animating at 60fps this second form is the right one.
When a Painter is the wrong tool
The honest boundary. Use a Painter when the drawing is content — something that
could plausibly have been an image, that needs a size, that gets passed around.
Use Modifier.drawBehind / drawWithContent when the drawing is decoration on an
existing element: an underline, a badge dot, a selection highlight. Those don't need an
intrinsic size and don't get passed anywhere.
Use Canvas when you're drawing a whole component — a chart, a signature pad, a game
scene. Canvas is itself a composable with a DrawScope, which is the right level when
nothing else is being decorated.
The test: would you ever pass this to an Image? If no, it isn't a Painter.
How to prove it
The intrinsic-size behaviour is the part to verify, since it's what distinguishes a
Painter from a draw lambda:
@Preview
@Composable fun PainterSizing() = Column {
Image(ShimmerPainter(gray, 0.5f), null) // unspecified → fills
Image(ShimmerPainter(gray, 0.5f), null, Modifier.size(48.dp)) // constrained
}
A painter with Size.Unspecified and no modifier fills its parent; one returning a
concrete intrinsicSize sizes itself. Getting that wrong shows up as an image that
collapses to zero or expands to fill the screen.
For the animation cost, Layout Inspector's recomposition counts: the mutable-state version should show zero recompositions while shimmering. If it climbs at 60/second, the progress value is being read in composition.
What this generalizes to
The idea is an interface that carries both behaviour and metadata. Painter isn't
just "a thing that draws" — it's "a thing that draws and knows how big it wants to be",
and that second half is what lets it slot into a layout system alongside real assets.
The pattern recurs wherever content and container negotiate: a font knows its metrics, a video knows its dimensions, a chart knows its aspect ratio. Drawing without declaring a size is fine for decoration and insufficient for content, which is exactly the line this abstraction draws.
The Painters you already use
Worth knowing what's in the box before writing one, because three of the four common needs are covered:
painterResource(id)— a drawable, vector or bitmap, with the intrinsic size taken from the asset.BrushPainter(brush)— any of tomorrow's gradients as a painter, which covers most placeholder and decorative-fill cases without a custom class.ColorPainter(color)— a flat fill; the simplest possible painter and a good read if you want to see the interface used minimally.rememberVectorPainter(imageVector)— for anImageVectorbuilt in Kotlin rather than loaded from resources.
BrushPainter in particular removes the reason to write the shimmer class above in many
cases — an animated brush passed to it does the same job. Reach for a custom Painter
when the drawing has structure a single brush can't express.
Tomorrow, Day 58: brushes and gradients — the fill side of drawing, and why a gradient defined in pixels breaks on a different screen.
Day 57 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Custom painter.