Your gradient is measured in pixels, and that's the bug

Compose gradients take pixel coordinates, so a hardcoded end point produces a different result on every screen size. Float.POSITIVE_INFINITY and DrawScope.size are the two ways to make a brush relative.

6 min read
androidcomposekotlingraphics

Day 58 — Your gradient is measured in pixels, and that's the bug

Day 58 of 100. Gradients are the most common custom drawing in an app, and they carry a coordinate-system trap that only shows up on a device you didn't test.

The symptom

A header gradient that's wrong on a tablet:

Box(
    Modifier
        .fillMaxWidth()
        .height(200.dp)
        .background(
            Brush.horizontalGradient(
                colors = listOf(Purple, Blue),
                startX = 0f,
                endX = 1080f,          // "the screen width"
            )
        )
)

On a 1080px-wide phone it's perfect. On a 1440px phone the gradient completes at three-quarters and the last quarter is flat blue. In a split-screen pane at 600px it's cut off part-way through the ramp.

The number was measured once, on one device, in pixels.

Why the obvious fix fails

The obvious fix is to look up the screen width:

val widthPx = with(LocalDensity.current) {
    LocalConfiguration.current.screenWidthDp.dp.toPx()
}
Brush.horizontalGradient(listOf(Purple, Blue), endX = widthPx)

Better, and still wrong for the actual requirement. The gradient should span the shape it fills, which may not be the screen — a card inset by 32dp, a pane in a two-pane layout, a chip. And it recomputes on every configuration change for a value the drawing system already knows.

Brush coordinates are absolute pixels; infinity means "to the end of whatever this fills"

The actual mechanism

Brush coordinates are absolute pixels in the drawing space, not fractions. The default end value is Float.POSITIVE_INFINITY, which is a sentinel meaning "the far edge of whatever this brush is applied to":

Brush.horizontalGradient(listOf(Purple, Blue))     // 0 → the shape's own width

That's the whole fix. Omit the coordinates and the gradient is relative to the element, correct at every size, with no configuration lookup.

When you do need explicit stops, get the size from the draw scope rather than the screen:

Modifier.drawBehind {
    drawRect(
        Brush.horizontalGradient(
            colors = listOf(Purple, Blue),
            startX = size.width * 0.2f,      // 20% in
            endX = size.width * 0.8f,        // 80% across
        )
    )
}

size inside a DrawScope is the element's own size in pixels — Day 57's point, and the reason draw-scope drawing composes better than a Modifier.background with baked constants.

The brush types

Five, and they differ in how they map a position to a colour:

Brush.linearGradient(colors)                       // along an arbitrary line
Brush.horizontalGradient(colors)                   // linear, left→right
Brush.verticalGradient(colors)                     // linear, top→bottom
Brush.radialGradient(colors, center, radius)       // outward from a point
Brush.sweepGradient(colors)                        // around a point, by angle

Plus SolidColor(color), which is a Brush too — useful because it means anything taking a Brush also takes a flat colour without a separate code path.

sweepGradient is the one people don't know exists and reach for a library to get: it's what a circular progress ring or a colour wheel needs, and it's one call. Its one quirk: the sweep starts at 3 o'clock, so a ring that should begin at the top needs Modifier.rotate(-90f).

Colour stops

By default colours are distributed evenly. colorStops places them:

Brush.verticalGradient(
    colorStops = arrayOf(
        0.0f to Color.Transparent,
        0.6f to Color.Transparent,
        1.0f to Color.Black.copy(alpha = 0.7f),
    )
)

That's the scrim under a photo caption — transparent for the top 60%, then darkening. It appears in nearly every media app and is worth having as a shared brush rather than retyped per screen.

Uneven stops are also how you get a sharp transition: two stops at the same position produce a hard edge rather than a ramp, which is how a two-tone background or a progress bar fill is drawn without two shapes.

TileMode, for when the shape is bigger than the gradient

If you give explicit coordinates that don't span the shape, TileMode decides what happens in the remainder:

Brush.horizontalGradient(
    colors = listOf(Purple, Blue),
    endX = 100f,
    tileMode = TileMode.Repeated,      // stripes
)

Clamp (the default) extends the last colour — which is exactly the flat-blue tail from the top of this post. Repeated tiles, Mirror alternates direction. A repeating gradient is how you draw a striped or hatched fill in one call, with no loop.

Brushes work on text too

Not obvious, and genuinely useful:

Text(
    "Premium",
    style = TextStyle(
        brush = Brush.linearGradient(listOf(Gold, Amber)),
    ),
)

Gradient text with no bitmap and no mask. It respects the type scale, wraps normally and stays selectable, which a rasterised gradient title does not.

The same applies to Modifier.border(width, brush, shape) — gradient borders are a brush, not a special API.

Banding, and why it doesn't show in a screenshot

A long, subtle gradient — near-black to black over 800dp — shows visible steps on an 8-bit-per-channel display. The intermediate colours quantise, and the eye is very good at spotting where the boundaries land.

Two mitigations. Add slight noise so the quantisation is irregular rather than aligned in straight bands. Or avoid gradients with very little colour distance over a very long span, which is the underlying cause: a smooth ramp needs enough distinct values between its endpoints to fill the distance.

It's the one gradient problem that never reproduces in a design review, because image compression hides it. Check it on a device, with the screen brightness down, which is where users will meet it.

The cost

A gradient is a shader, and building one is not free. Creating a Brush inside a composable that recomposes frequently allocates per composition:

// Allocates every recomposition
Box(Modifier.background(Brush.verticalGradient(listOf(a, b))))

// Allocated once
val brush = remember(a, b) { Brush.verticalGradient(listOf(a, b)) }
Box(Modifier.background(brush))

For a static background this rarely matters. Inside a LazyColumn item or an animating component it does, and remember with the colours as keys is the whole fix.

How to prove it

The relative-coordinate claim is one preview at three widths:

@Preview(widthDp = 320) @Preview(widthDp = 600) @Preview(widthDp = 1000)
@Composable fun GradientHeader() = AppTheme { Header() }

A relative gradient completes at every width. A pixel-coordinate one is visibly different in each render — the failure is obvious once you look at three sizes together, and invisible when you look at one.

For the allocation, the recomposition counts plus a log in the brush construction. If it prints per frame during an animation, it needs a remember.

What this generalizes to

The lesson is relative beats absolute, and sentinels beat lookups. The gradient doesn't need to know the screen width; it needs to know its own extent, and the drawing system already does. Float.POSITIVE_INFINITY as "to the edge" is a small API decision that removes an entire class of bug.

It's the same shape as Day 35's innerPadding and Day 29's window size classes: at every level, the fix was to stop substituting a measured constant for a value the system can provide. Three pillars, one habit.

The tell is always the same: a number in the source that came from measuring one device. 1080f, 64.dp for an app bar, 600 for a tablet breakpoint — each one is a snapshot of a configuration that will not hold, and each has a system-provided alternative that does.

Tomorrow, Day 59: shapes and shadows — drawing paths, and why elevation looks different in Material 3.


Day 58 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Brush.