Skip to content

Core conceptsΒΆ

Haze separates captured input, shareable configuration, and node-owned rendering resources.

SourcesΒΆ

HazeState connects one or more hazeSource modifiers to source-backed effects:

val hazeState = rememberHazeState()

LazyColumn(
  modifier = Modifier.hazeSource(hazeState),
) {
  // Content
}

Sources can have a zIndex and metadata key. HazeSourceSelection.Behind is the default. With a nearest ancestor hazeSource using the same state, it selects lower-z sources; without one, it selects every source. HazeSourceSelection.All bypasses the ancestor relationship.

Refine either selection with where. Predicates receive only immutable key and zIndex metadata, not captured pixels or renderer resources. Repeated refinements combine with logical AND:

val selection = HazeSourceSelection.Behind
  .where { source -> source.zIndex >= 1f }
  .where { source -> source.key != "sensitive" }

Explicit inputsΒΆ

Typed effects always declare what they consume:

  • HazeInput.Sources(hazeState) consumes captured source content.
  • HazeInput.Content consumes the modifier's own content.

Source-backed input also declares retention:

HazeInput.Sources(
  state = hazeState,
  retention = HazeSourceRetention.ClearWhenUnavailable,
)

KeepLastFrame smooths temporary source gaps. ClearWhenUnavailable clears retained output as soon as no selected source is drawable.

Typed BlurΒΆ

Blur has an ordinary typed modifier:

Modifier.hazeBlur(
  input = HazeInput.Sources(hazeState),
  style = HazeMaterials.thin(),
  sampling = HazeSampling.Adaptive,
  expandLayerBounds = true,
)

Use HazeInput.Content for own-content Blur. The structural input, retention, sampling, and layer expansion policies do not live in HazeBlurStyle.

HazeBlurStyle is an opaque replayable program:

val style = HazeBlurStyle {
  blurRadius(20.dp)
  colorEffects(
    listOf(HazeColorEffect.tint(Color.White.copy(alpha = 0.12f))),
  )
}.then {
  noiseFactor(0f)
}

Resolution replays HazeBlurDefaults.style, LocalHazeBlurStyle, and the explicit Style in order. The last write wins, and every evaluation starts fresh. Styles contain no mutable renderer or platform state and can be shared by concurrent modifiers.

Typed custom effectsΒΆ

Custom effects use a stateless factory and one renderer per modifier node:

val factory = HazeEffectFactory<MyStyle> {
  object : HazeEffectRenderer<MyStyle> {
    override fun HazeEffectDrawScope.draw(style: MyStyle) {
      drawInput()
      // Draw the effect.
    }
  }
}

Modifier.hazeEffect(
  factory = factory,
  input = HazeInput.Content,
  style = MyStyle(...),
)

The renderer can own mutable resources and releases them in dispose. Style replacement updates the existing renderer. Factory replacement and detachment dispose it exactly once.

SamplingΒΆ

  • HazeSampling.Default and Adaptive let built-in effects balance quality and cost automatically.
  • HazeSampling.FullResolution uses the full input resolution.
  • HazeSampling.Fixed(pixelFraction) uses an explicit fraction of the full-resolution input pixels.

Start with the default. Choose FullResolution only when visual comparison shows that you need it, or Fixed when you deliberately want a stable quality and performance trade-off.

Layer boundsΒΆ

expandLayerBounds lets an effect request a larger capture layer. Blur normally expands by its resolved radius to avoid edge artifacts. Disable it only when the surrounding pixels must not be captured.

Background and foreground effectsΒΆ

Source-backed effects render captured content from elsewhere in the hierarchy:

Box {
  LazyColumn(
    modifier = Modifier.hazeSource(hazeState),
  ) {
    // Content
  }

  TopAppBar(
    modifier = Modifier.hazeBlur(
      input = HazeInput.Sources(hazeState),
    ),
  )
}

Own-content effects capture and transform the modifier's content:

Box(
  modifier = Modifier.hazeBlur(input = HazeInput.Content),
) {
  // This content is blurred.
}

Deep UI hierarchiesΒΆ

When HazeState would otherwise pass through many composables, provide it through a composition local:

val LocalHazeState = compositionLocalOf { HazeState() }

@Composable
fun HazeExample() {
  val hazeState = rememberHazeState()

  CompositionLocalProvider(LocalHazeState provides hazeState) {
    Box {
      Background()
      Foreground()
    }
  }
}

@Composable
fun Foreground() {
  Text(
    modifier = Modifier.hazeBlur(
      input = HazeInput.Sources(LocalHazeState.current),
    ),
  )
}

Overlapping effectsΒΆ

One composable can both consume lower sources and become a source for a higher effect. Give each source an explicit zIndex:

Box {
  Background(
    modifier = Modifier.hazeSource(hazeState, zIndex = 0f),
  )

  Card(
    modifier = Modifier
      .hazeSource(hazeState, zIndex = 1f)
      .hazeBlur(input = HazeInput.Sources(hazeState)),
  )

  TopAppBar(
    modifier = Modifier
      .hazeSource(hazeState, zIndex = 2f)
      .hazeBlur(input = HazeInput.Sources(hazeState)),
  )
}

The Card consumes the Background, while the TopAppBar consumes both lower sources.

DialogsΒΆ

Mark the source before showing a dialog. Haze can then align a source and effect that live in different windows:

Box {
  LazyColumn(
    modifier = Modifier.hazeSource(hazeState),
  ) {
    // Background content
  }

  if (showDialog) {
    Dialog(onDismissRequest = { showDialog = false }) {
      Surface(
        modifier = Modifier.hazeBlur(
          input = HazeInput.Sources(hazeState),
        ),
      ) {
        // Dialog content
      }
    }
  }
}

Haze handles alignment between the dialog and its source automatically.

Screenshot testingΒΆ

On Android, run Robolectric screenshot tests against SDK 35 or newer. Earlier Robolectric SDK levels do not fully reproduce the blur tile modes used at effect edges:

@Config(sdk = [35])
class MyScreenshotTest {
  // Tests
}

This limitation affects the test environment, not the equivalent effect on a physical device.