Skip to content
All posts

A shadow with nothing to hide behind

Hovering an unselected filter chip painted a black block across it. The diagnosis needed a control group, the first fix was the wrong shape, and applying the rule instead of the symptom surfaced a second bug eight times larger.

8 min read
  • Material 3
  • Debugging
  • Compose

Hovering an unselected filter chip filled it with a black block.

Not a subtle darkening. On the cream preview canvas the chip's interior went from 250,249,245 to roughly 184,183,179 — about 26% black across the whole footprint, with a lighter patch in the middle. On a 32dp chip that reads as a ~7dp dark band all the way around, which nobody would describe as a shadow. It looks like a heavy black border that appears when your mouse arrives.

Two things about how it was found are worth saying before the diagnosis, because both are more transferable than the bug.

It was found in a browser, hovering a chip on this site's live Wasm preview. It could not have been found on a phone. Hover is a pointer state, and the Android sample app runs on a touchscreen where that state effectively never occurs. Compiling the component library to Wasm for the docs site was a documentation decision; it turned into a second test surface with a pointer attached, which was not the plan.

And it was found by hovering. Not by a test — there is no assertion anywhere in this library, or realistically in yours, that says "the hover state of this component does not paint a dark rectangle."

It's a shadow

The first useful move was recognising the shape: an even dark region matching the component's own footprint is what an unoccluded shadow looks like.

Two conditions have to coincide to get one, and only one chip state in the library met both.

Surface only inserts a shadow graphics layer when elevation is non-zero. No elevation, no layer, no shadow — that's the gate.

Material 3 gives the unselected flat filter chip a Color.Transparent container. That's the flat look, and it's correct.

Put them together and you have a shadow being cast beneath something you can see straight through. Normally the umbra is hidden by the opaque thing sitting on top of it — that's the entire visual premise of a drop shadow. With a transparent occluder there's nothing to hide behind, and Skia paints the umbra straight across the occluder's own footprint rather than culling it. What you see is the shadow the chip is casting on itself.

Where the elevation came from

The chip was never given an elevation. It inherited one, from a Material 3 default that is arguably a bug in Material 3.

FilterChipTokens declares a token named FlatUnselectedHoverContainerElevation and sets it to Level0 — zero. Exactly the right value. It is then never read. filterChipElevation() hands the selected chip's 1dp hover elevation to both states, so an unselected filter chip lifts by 1dp on hover, contradicting a token that exists in the same file to say it shouldn't.

One dp. On an opaque container that's an invisible refinement. On a transparent one it's a black block.

The control group

This is the part that turned a plausible story into a confirmed one, and it's the habit I'd most want to pass on: before believing a two-condition explanation, go find the things that satisfy one condition and not the other, and check they behave as the theory predicts.

Buttons take the identical 1dp hover elevation. None of them show the artifact, and they escape by two different routes:

ComponentContainerElevationResult
Filled, Tonal, Elevated buttonopaque1dp on hoverFine — the container hides the umbra
Outlined, Text buttontransparentnullFine — no shadow layer is ever created
Unselected flat filter chiptransparent1dp on hoverBlack block

Both escape routes are informative, but the second one is the load-bearing one. The Outlined button has a fully transparent container and looks perfect, which falsifies the simpler theory — "transparent containers are broken" — and forces the conjunction. You need transparency and a non-zero elevation. Either alone is harmless.

If I'd stopped at the first explanation that fit the evidence, I'd have shipped a fix aimed at transparency and been puzzled later.

The first fix was the wrong shape

The obvious repair: override hoveredElevation to zero on the filter chip. One field, one variant, symptom gone. That's what went in first.

It was wrong, and the way it was wrong is more interesting than the bug. A fix shaped like the symptom can only ever cover the symptom, and its narrowness is camouflage — the code now looks deliberate, so nobody revisits it.

The better fix came from asking what elevation actually buys on a chip. Reading Material 3's Chip.kt settles it: a chip's elevation feeds exactly one thing, Surface's shadowElevation, on both code paths. Not tonal elevation. Not colour. Not the state layer. Just the shadow.

That reframes the whole thing. If elevation buys nothing but a shadow, then the question isn't "what dp value is right here," it's "can a shadow render correctly here at all?" — and that's decidable from the container colour alone, with no reference to hover, or to filter chips, or to the specific defect that started this.

So: pass null wherever the container is transparent.

FormaChipVariant.Filter -> FilterChip(
    …
    elevation = if (selected) FilterChipDefaults.filterChipElevation() else null,
)

FormaChipVariant.Suggestion -> SuggestionChip(
    …
    elevation = null,
)

Four of Material 3's chip call sites reach a Color.Transparent container — flat Assist, unselected flat Filter, unselected Input, and flat Suggestion. Those get elevation = null. Selected Filter and Input have opaque containers and keep M3's stock elevation object, deliberately not a hand-rolled one, so they keep tracking upstream if Google retunes those values.

What the narrow fix was hiding

Applying the rule instead of patching the symptom immediately surfaced a second instance, considerably larger than the first.

All four chip types default draggedElevation to Level48dp. Eight times the 1dp hover lift that started this. Same transparent container, same missing occluder, same artifact, except now the umbra extends far enough that the chip would be a dark smear while you drag it.

Nothing in the component emits DragInteraction today, so it's latent rather than observed. It stops being latent the moment a caller passes a drag-aware interactionSource — which is a supported public parameter, and exactly what anyone building a reorderable chip row would do.

Worth stating plainly, and this is where I'd flag my own writeup: unlike the hover bug, I have not reproduced this one on screen. It follows from the same mechanism and the same numbers, and the mechanism is now well-supported, but "follows from a confirmed mechanism" is a weaker claim than "I watched it happen," and the two deserve different confidence.

The earlier note in this codebase said Assist, Input and Suggestion chips were safe because their hover, press and focus elevations are all Level0. That was true, and it's still true — the hover regression really was filter-chip-only. But "safe in the states we looked at" is not the same as "correct," and drag was the state we hadn't looked at. The symptom-shaped fix is what made it feel like the question was closed.

Both root causes are still upstream

Neither of the real defects is fixed by any of this:

  • Material 3 declares FlatUnselectedHoverContainerElevation and never reads it.
  • Skia paints a shadow umbra beneath a fully transparent occluder instead of culling it.

What shipped is a local workaround, and it's worth being clear about the distinction — a workaround you've labelled as a workaround is maintainable, while a workaround you've forgotten is a mysterious null that the next person deletes during a cleanup. The rule lives in a comment next to the code that applies it, and the KDoc explains it to callers, because the whole point is that someone adding a fifth chip variant should be able to derive the right answer rather than remember this incident.

What transfers

  • A uniform dark region the size of the component is a shadow, not a border and not a state layer. Recognising the artifact is most of the diagnosis.
  • Transparent containers and drop shadows are incompatible. Any component that can be both flat and elevated has this bug latent in it.
  • Find the control group before you believe your explanation. The Outlined button — transparent and fine — is what proved the conjunction and killed the simpler theory.
  • Fix the rule, not the symptom. "Suppress elevation where the container is transparent" and "set hoveredElevation = 0 on filter chips" fix the same screenshot; only one of them finds the 8dp drag case.
  • Ask what a parameter actually buys before tuning it. Elevation on a chip buys precisely one shadow, which is what makes null obviously right and a dp value obviously a guess.
  • A component library inherits its dependency's bugs, including the ones the dependency contradicts in its own token file. Wrapping something means you own how it looks.
  • Give your components a surface with a pointer. A touchscreen sample app cannot exercise hover, and hover states are where this class of bug lives.

The last one is the one I didn't expect. The Wasm previews on this site exist so people can try the components without cloning anything. That they also constitute the only place this library gets hovered was a side effect — and it's now the second thing I'd check when a visual state looks wrong.


FormaUI is an opinionated Material 3 component library for Jetpack Compose — 40 components with the design work already done. You can hover the chip yourself, in a browser, along with every other component — which is how this one was found.

Back toAll posts