Build refracting "liquid glass" surfaces in Compose with a backdrop library, and avoid the three failures that waste the most time — a rim highlight that is directional by default and so goes nearly invisible on small round buttons, a backdrop source nested inside the glass it feeds, which is a render-feedback loop that stops the shader, and a source with nothing in it, which renders the control as a grey coin over a flat page. Covers the source/surface split, giving a flat page a ground worth refracting rather than dropping the effect, the white default tint that only suits forced-dark screens, the effect stack, keeping the press gesture observe-only, and the swap experiment that tells a geometry problem from a placement problem. Use when a glass surface renders as a flat rounded box or a grey coin, when the rim shows on a wide pill but not on a circular button, when its glyph disappears at light theme, or when the draw pass crashes inside the shader.
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill liquid-glass-backdrop
A backdrop library splits the effect in two. A source modifier records a composable's rendering
into a graphics layer; a draw modifier on the surface samples that recording, pushes it
through an effect stack, and paints the result behind itself:
// adapted
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { shape }, highlight = { highlight },
effects = {
vibrancy()
colorControls(brightness = 0.05f, contrast = 1f, saturation = 1.5f)
blur(radius)
// refraction height below the inradius (minDimension / 2), so top and bottom refractions
// never meet at the medial axis — that meeting is the dark seam on a wide pill
lens(size.minDimension / 4f, size.minDimension / 2f, false)
},
// darken as the background brightens, or bright artwork washes the glass out to white
onDrawSurface = { drawRect(scrimColor.copy(alpha = darken)) },
)
Three moving parts, in order of trouble caused: the rim highlight, the **source/surface
relationship**, and the effect stack — the stack being a list of filters you tune by eye.
The rim highlight's default style is directional, not an outline. This is why a wide pill looks
like glass and a 48 dp circular button beside it looks like nothing — same modifier, same parameters.
Read the default style's shader rather than guessing:
// annotated — the library's own shader body, re-indented and `=`-aligned; the comments are added
float2 grad = gradSdRoundedRect(centeredCoord, halfSize, gradRadius); // outward edge normal
float2 normal = float2(cos(angle), sin(angle)); // one light direction
float d = dot(grad, normal);
float intensity = pow(abs(d), falloff);
return color * intensity;
Brightness at a point on the outline is the alignment between that point's edge normal and one
fixed direction. On a stadium an entire straight edge shares one axis-aligned normal, so both long
edges light to a constant at once — most of the outline, uniformly, which reads as a rim. On a circle
the normal sweeps every direction, so intensity runs |cos(θ − angle)|: full at two opposing points,
zero at the two 90° away, under 70% over half the circumference — nothing left on a 48 dp button.
Two independent levers fix it. Swap the style for the library's uniform one (a colour and a blend
mode; no angle, no falloff, therefore no direction). Or **widen the rim, keeping the directional
style**: width feeds strokeWidth = ceil(width.toPx()) * 2, so the 0.5 dp default is the thinnest
stroke the library draws and Highlight(width = 1.dp) puts back enough pixels for the surviving arc.
The audited tree ships the *second* at every site that reaches for a lever, keeping them matched to
the pills beside them as a style swap would not. In the 2.0.x line resolved here the default is white
at 0.5 alpha, additive, 45°, falloff 1; the uniform style white at 0.38 alpha, additive.
The backdrop source must be a sibling of the glass, never its parent. The source records what it
draws and the surface reads that recording while drawing itself, so a surface *inside* the source
makes the recording depend on its own output — the loop stops the shader in the draw pass:
// adapted — the source is the content column; the glass buttons are siblings placed with align()
Box(Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().layerBackdrop(headerBackdrop)) {
Spacer(Modifier.height(48.dp)) // reserves the strip the buttons sit over
…artwork and text…
}
GlassIconButton(headerBackdrop, …, modifier = Modifier.align(Alignment.TopStart))
}
**A source that draws nothing gives the glass nothing to bend — and the cure is a ground, not a
retreat.** Over an empty region the output is the surface scrim and nothing else: on a flat page the
control renders as a grey coin, worse than no chrome at all. That is a full arc here — glass
introduced, then *removed* for a plain icon button with exactly that reasoning written into the
commit, then restored once the page had a ground worth refracting. The ground is a matchParentSize()
box carrying the page colour plus the page's ambient tone, and the source is that box — never the
content column, which is mostly transparent and rendered the control as a solid dark coin over a
tinted page. Where the content genuinely draws (a list whose first item is a full-width image header)
it is the right source, its strip reserved by a Spacer. The glass must also *float* over the
ground rather than ride the scrolling column, which scrolls it away on a flat page.
The default tint assumes a forced-dark screen. These controls are usually built first for screens
inside a forced-dark subtree, where a white glyph always sits over dark artwork and Color.White is
sound. On a theme-following page it is white-on-white at light theme — and the flat fallback an
off-setting swaps in is light too, so the glyph vanishes in both materials. Pass the scheme's
on-surface token instead; force-dark-immersive-subtree is why the donor screens never hit this.
Run the discriminating experiment before changing the code. When one glass widget works and
another does not, swap their positions: if the effect follows the *widget* the cause is its own
geometry (the rim trap above), if it follows the *position* it is what the source records there. Four
rebuilds went on plausible fixes first — widening the button to 96 dp (a longer edge catches more of
the sweep, so the rim *does* appear: a symptom, not a fix), shrinking the lens radii, moving the
source between a full-size box and the content column, and painting artwork in at low alpha.
Keep the press gesture observe-only. The glass scales up and draws a pointer-following glow on
press, and it wraps buttons that need their own clicks. The recogniser must never consume(), must
accept already-consumed downs (awaitFirstDown(requireUnconsumed = false)), and must bail when a
change *has* been consumed — or the surface animates while the button inside it stops responding.
Check whether the library ships a variant for your other targets before writing a no-op actual.
Assuming a shader-heavy effect is one-platform and stubbing the desktop actual leaves it dead code
there that nobody notices — it compiles and renders a plain rounded box. If the artifact is
multiplatform the abstraction has nothing to abstract: delete the expect class, keep a typealias
so call sites do not churn, and move the effect into common code — which the class blocked anyway.
cache, no build. The range starts at the uniform block, not at half4 main:
JAR=$(find ~/.gradle/caches -name '*backdrop*.jar' | head -1)
DEF=$(unzip -l "$JAR" | awk '/HighlightStyle\$Default\.class/{print $4}')
PLAIN=$(unzip -l "$JAR" | awk '/HighlightStyle\$Plain\.class/{print $4}')
unzip -p "$JAR" "$DEF" | strings -n 6 | sed -n '/uniform float2 size/,/return color/p'
unzip -p "$JAR" "$PLAIN" | strings -n 6 | grep -c 'angle\|falloff'
→ observed: uniform float angle and uniform float falloff declared, then the
dot(grad, normal) main quoted above; then 0 — the uniform style ships no shader source at all.
grep -rn 'layerBackdrop(' --include='*.kt' . | grep -v 'fun Modifier'
→ observed: every hit is a Box/Column *sibling* of the glass it feeds — including the analytics
screen, whose source is the LazyColumn itself (first item an image header, so it draws), buttons
placed after it by align(). A source whose subtree holds a drawBackdrop for the same backdrop
is the crash; a bare transparent source is the grey coin.
grep -rn 'highlight = ' --include='*.kt' . | grep -v 'fun \|highlight: '
→ observed: six sites pass Highlight(width = 1.dp) (the wider-rim lever, style left directional),
one bar passes Highlight.Default.copy(alpha = …), nothing passes the uniform style. Two 48 dp
back buttons on the theme-following pair are on the bare default — the first ones to look at.
only in the pill state is the highlight trap. If both levers rescue it, geometry is confirmed.
Related: compose-desktop-runtime-hardening (shader-adjacent crashes from the rendering backend
rather than your draw code) and shell-background-is-not-scheme-background (what a ground is made of).
Take maxrave-dev/liquid-glass-backdrop from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.