Blurred backdrop for dialogs and overlays that live in their own window, down to API 23.
Snapshot behind a Dialog |
Fallback in a translucent activity |
|---|---|
![]() |
![]() |
Both from :sample on one device.
Dialog(onDismissRequest = ::dismiss) {
Box(
modifier = Modifier
.fillMaxSize()
.blurredUnderlay(
blurRadius = 16.dp,
tint = Color.Black.copy(alpha = 0.3f),
fallback = MaterialTheme.colorScheme.surface,
),
) {
// dialog content
}
}Compose blur libraries — haze,
Cloudy, imla — blur a
composable subtree: you mark it with a modifier, they capture it into a GraphicsLayer and blur
that layer.
A Dialog renders in a separate window, and the content behind it belongs to the activity's
window — a different render node tree. A modifier inside the dialog cannot reach it. The platform
gap is tracked upstream as
Support window blur in compose dialogs.
Underlay covers that one case. For blur inside your own window use haze or Cloudy; the two combine, with Underlay drawing the backdrop and haze the live effect on top.
The backdrop comes from the best source the device offers:
| Source | Requires | What happens |
|---|---|---|
SystemBlur |
API 31+, own window | FLAG_BLUR_BEHIND + blurBehindRadius. Live, on the GPU, free. Only the tint is drawn on top. |
Snapshot |
API 23+, reachable host window | One PixelCopy of the host window (decorView.draw() below API 26), downscaled ×4, then blurred: RenderEffect on the GPU from API 31, box passes on Dispatchers.Default below it. |
Fallback |
— | Solid fallback color. |
A capture takes a frame boundary, a PixelCopy and a blur. Until it lands the modifier reports a
fourth value, Pending, and draws only the tint, so the overlay settles from clear host content
into the blurred snapshot instead of flashing an opaque fallback.
The system switches cross-window blur off in battery saver. Underlay listens for that through
WindowManager.addCrossWindowBlurEnabledListener, drops the window flag and falls through to the
snapshot.
The snapshot uses PixelCopy wherever it exists. decorView.draw() into a software canvas throws
Software rendering doesn't support hardware bitmaps as soon as an image loader has decoded
anything on screen into one, which is the Coil and Glide default from API 26.
Underlay resolves two windows: the one the caller lives in, and the host window underneath.
| Called from | Own window | Host window | Best source |
|---|---|---|---|
Dialog |
dialog window | activity window | SystemBlur |
Popup, PopupWindow |
root view added through WindowManager |
activity window | SystemBlur |
| Translucent or floating activity | activity window | none — it belongs to another task | SystemBlur |
| Plain overlay inside the activity | none | none | Fallback |
Without an own window the host window is the caller's own window, so capturing it would fold the
overlay into its own backdrop. That row draws fallback.
A translucent activity has no host window either, so it drops from SystemBlur straight to
Fallback where a Dialog or a Popup would drop to Snapshot.
Modifier.blurredUnderlay(
blurRadius = 16.dp,
tint = Color.Black.copy(alpha = 0.3f),
fallback = MaterialTheme.colorScheme.surface,
onSourceChange = { source -> Log.d("underlay", "source: $source") },
)UnderlaySource is SystemBlur, Snapshot, Pending or Fallback. The callback fires on every
change, including the fall-through when the system turns cross-window blur off.
Snapshot freezes the host window. To capture it again while the overlay is open, hand the modifier
an UnderlayState:
val underlay = rememberUnderlayState()
Box(
modifier = Modifier.blurredUnderlay(
blurRadius = 16.dp,
tint = Color.Black.copy(alpha = 0.3f),
fallback = MaterialTheme.colorScheme.surface,
state = underlay,
),
) {
Button(onClick = { underlay.refresh() }) { Text("Refresh") }
}The snapshot on screen stays until the new one is ready, so a refresh does not flash. SystemBlur
blurs live and ignores the state.
dependencies {
implementation("com.timkrest:underlay:0.2.0")
}minSdk 23, the floor Compose itself declares, and Java 11 bytecode. Dependencies: Compose runtime
and UI, exposed as api, plus activity-compose, core-ktx, annotation and
kotlinx-coroutines-core.
The public surface is recorded in underlay/api/underlay.api and
checked on every build.
- A patch release never breaks anything, in source or in binary.
- Until 1.0.0 a minor release may, and says so in the changelog. CI refuses a release that drops a declaration without leaving the series it was published in.
- Kotlin mangles every signature taking a
Dpor aColor—blurredUnderlay-6Ivg_Sk. Adding a parameter renames it, so a release can keep your call sites compiling and still need a rebuild. A release that needs the call site itself edited says so in the changelog.
- The snapshot is frozen. It captures the host window once, again when that window changes size
or configuration, and on
UnderlayState.refresh(). Content moving underneath is not picked up on its own; for a popup over a scrolling list use an in-window library. A failed re-capture keeps the snapshot on screen, unless the host resized and the old frame no longer lines up — then the snapshot is dropped and only the tint is drawn until a capture succeeds, andfallbackonly if none does. FLAG_BLUR_BEHINDis a window flag.SystemBlurblurs everything behind the overlay window;Snapshotblurs only what is behind the composable. Apply the modifier to a composable that fills the overlay window to get the same picture from both.blurRadiusis a Gaussian sigma, the meaning Figma andRenderEffect.createBlurEffectgive it, taken on the ×4 downscale inSnapshot. On the CPU it becomes the box radius whose three passes land nearest,radius = (sqrt(4*sigma^2 + 1) - 1) / 2rounded to the closer neighbour.- The host is always the activity window. Under a dialog opened over another dialog,
Snapshotcaptures the activity, not the dialog in between.SystemBlurhas no such seam.
:sample opens one overlay per window kind — Dialog, Popup, translucent activity, plain
in-window overlay — over a grid of hardware bitmaps, reports the active source and lets you drag the
radius. Under SystemBlur it points at Battery Saver, which triggers the fall-through to Snapshot
on a real device.
./gradlew :sample:installDebugKotlin Multiplatform is not planned. The gap this library covers is Android's: on iOS Compose draws
dialogs into the same view hierarchy, so there is no window boundary and UIVisualEffectView
already covers blur. Desktop has the boundary but nothing in common with FLAG_BLUR_BEHIND or
PixelCopy — a second implementation, not shared code. Only the box blur is portable.
Apache 2.0 — see LICENSE.

