Skip to content

Blur Effects

coui-blur is a standalone blur effect library for Compose Multiplatform. It provides backdrop blur, color blending, and texture effects via Modifier extensions. The library supports Android, Desktop (JVM), iOS, macOS, and Web (WasmJs/Js).

WARNING

On Android, coui-blur requires minSdk 33 (Android 13) or higher. All effects (blur, blend, noise, highlight) rely on RuntimeShader, which is only available from API 33. Apps with a lower minSdk that still want to include this library should gate blur-related code paths with the capability checks described below.

Setup

Add the coui-blur dependency to your project:

kotlin
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("io.github.suqi8.coui.kmp:coui-blur:<version>")
        }
    }
}

For Android-only projects:

kotlin
dependencies {
    implementation("io.github.suqi8.coui.kmp:coui-blur-android:<version>")
}

Platform Support

PlatformMinimum Requirement
AndroidAPI 33 (Android 13)
Desktop (JVM)Supported
iOS / macOSSupported
WasmJs / JsSupported

Blur is implemented as a separable Gaussian RuntimeShader wrapped in a RenderEffect. On Android, RuntimeShader was introduced in API 33, so that is the hard floor for the entire library — including blend, noise, and highlight effects.

Runtime capability checks

If your app has a lower minSdk than coui-blur and you want to keep shipping a single APK, gate blur usage with the capability checks below. On Android they map to Build.VERSION.SDK_INT comparisons; on Skiko-based targets (Desktop, iOS, macOS, Web) they always return true.

kotlin
import io.github.suqi8.coui.kmp.shader.isRenderEffectSupported
import io.github.suqi8.coui.kmp.shader.isRuntimeShaderSupported

// True on API 32+ (and all non-Android targets). Useful when you only need
// the backdrop scaffold (e.g. a chained ColorFilter via `colorFilter(...)`).
val backdropScaffoldSupported = isRenderEffectSupported()

// True on API 33+ (and all non-Android targets). Gate any code path that
// applies `blur(...)`, `blendColors(...)`, `noiseDither(...)`,
// `Modifier.textureBlur(...)`, the highlight styles, or any `RuntimeShader`
// you build yourself.
val blurAndBlendSupported = isRuntimeShaderSupported()

Basic Usage

Applying a backdrop blur involves three steps:

  1. Create a LayerBackdrop to capture the background content
  2. Apply Modifier.layerBackdrop() on the content container
  3. Apply Modifier.textureBlur() on the blur surface
kotlin
import io.github.suqi8.coui.kmp.blur.BlurColors
import io.github.suqi8.coui.kmp.blur.BlurDefaults
import io.github.suqi8.coui.kmp.blur.layerBackdrop
import io.github.suqi8.coui.kmp.blur.rememberLayerBackdrop
import io.github.suqi8.coui.kmp.blur.textureBlur

// Step 1: Create a LayerBackdrop
val backdrop = rememberLayerBackdrop()

// Step 2: Capture background content
Box(
    modifier = Modifier
        .fillMaxSize()
        .layerBackdrop(backdrop) // Captures this Box's content
) {
    // Background content (e.g., an image, gradient, or page content)
    Image(
        painter = painterResource(Res.drawable.background),
        contentDescription = null,
        modifier = Modifier.fillMaxSize(),
        contentScale = ContentScale.Crop
    )
}

Background Color

layerBackdrop only captures the content drawn by the composable it is applied to — it does not include backgrounds from parent composables (e.g., Scaffold's Surface). If the captured content has transparent areas (such as text without a background), the blur will spread colors into transparency, producing visible color artifacts.

To avoid this, draw an opaque background in the onDraw lambda:

kotlin
val backgroundColor = MaterialTheme.colorScheme.surface
val backdrop = rememberLayerBackdrop {
    drawRect(backgroundColor) // Ensures an opaque background is captured
    drawContent()
}
kotlin

// Step 3: Apply blur on an overlay surface
Box(
    modifier = Modifier
        .size(200.dp)
        .textureBlur(
            backdrop = backdrop,
            shape = RoundedCornerShape(16.dp)
        )
) {
    Text(
        text = "Blurred Card",
        modifier = Modifier.padding(16.dp)
    )
}

Color Configuration

Use BlurColors to apply color adjustments and blend layers on top of the blur:

kotlin
import io.github.suqi8.coui.kmp.blur.BlendColorEntry
import io.github.suqi8.coui.kmp.blur.BlurBlendMode
import io.github.suqi8.coui.kmp.blur.BlurColors

val colors = BlurColors(
    blendColors = listOf(
        BlendColorEntry(
            color = Color.White.copy(alpha = 0.3f),
            mode = BlurBlendMode.SrcOver
        )
    ),
    brightness = 0.05f,  // Range: [-1, 1], 0 = no change
    contrast = 1.1f,     // Multiplier, 1 = no change
    saturation = 1.2f    // Multiplier, 1 = no change
)

Box(
    modifier = Modifier
        .textureBlur(
            backdrop = backdrop,
            shape = RoundedCornerShape(16.dp),
            colors = colors
        )
) {
    // Content
}

You can also use the composable helper BlurDefaults.blurColors() which remembers the configuration:

kotlin
val colors = BlurDefaults.blurColors(
    blendColors = listOf(
        BlendColorEntry(Color.White.copy(alpha = 0.2f), BlurBlendMode.Screen)
    ),
    brightness = 0f,
    contrast = 1f,
    saturation = 1.2f
)

Blend Modes

BlurBlendMode provides 40+ blend modes for color blending over the blurred backdrop.

Standard Modes

Standard SkBlendMode values (0-28), using Skia-compatible premultiplied-alpha formulas:

ModeDescription
SrcOverNormal alpha compositing (default)
ScreenBrightening blend
MultiplyDarkening blend
OverlayContrast-enhancing blend
SoftLightSoft contrast adjustment
ColorDodgeBrightens by reducing contrast
ColorBurnDarkens by increasing contrast
DarkenKeeps darker pixels
LightenKeeps lighter pixels
DifferenceAbsolute difference
ExclusionSimilar to Difference but lower contrast
HueApplies source hue
SaturationApplies source saturation
ColorApplies source hue and saturation
LuminosityApplies source luminosity

Custom Modes

Custom modes (100+) implementing Lab color space operations, linear light blending, and more (requires isRuntimeShaderSupported()):

ModeDescription
LinearLightLinear light blend
LinearLightWithGreyscaleLinear light with greyscale modulation
LinearLightLabLinear light in Lab color space
LabLightenWithGreyscaleLab lighten with greyscale modulation
LabDarkenWithGreyscaleLab darken with greyscale modulation
LabLab color mapping
MiColorDodgeEnhanced color dodge
MiColorBurnEnhanced color burn
PlusDarkerPlus darker with alpha compositing
PlusLighterPlus lighter with alpha compositing
AlphaBlendAlpha blend with child modulation
MiSaturationSaturation adjustment
MiBrightnessBrightness adjustment
MiLuminanceLuminance curve adjustment

Example: Multiple Blend Layers

kotlin
val colors = BlurColors(
    blendColors = listOf(
        BlendColorEntry(Color(0x40FFFFFF), BlurBlendMode.Screen),
        BlendColorEntry(Color(0x20000000), BlurBlendMode.Overlay)
    ),
    saturation = 1.5f
)

Progressive Blur

Modifier.progressiveTextureBlur applies a gradient backdrop blur: the blur strength ramps continuously from full to zero along a direction, with a genuine medium blur in the middle of the ramp and a pixel-sharp, full-resolution clear end. Ideal for navigation bars and edge fades.

kotlin
import io.github.suqi8.coui.kmp.blur.ProgressiveBlur
import io.github.suqi8.coui.kmp.blur.progressiveTextureBlur

Box(
    modifier = Modifier
        .fillMaxWidth()
        .height(200.dp)
        .progressiveTextureBlur(
            backdrop = backdrop,
            shape = RectangleShape,
            blurRadius = 20f,
            gradient = ProgressiveBlur.Top // Strongest at the top, sharp at the bottom
        )
) {
    // Content
}

Gradient Direction and Band

ProgressiveBlur describes the ramp as a two-point linear gradient: the blur is at full strength at startFraction and fades to zero at endFraction, measured along angle (degrees, clockwise from the +X axis). Use the presets for the common edge fades, or adjust the band with copy:

kotlin
// Presets cover the four edge fades
ProgressiveBlur.Top      // angle = 90°, strongest at the top edge
ProgressiveBlur.Bottom   // angle = 270°, strongest at the bottom edge
ProgressiveBlur.Left     // angle = 0°, strongest at the left edge
ProgressiveBlur.Right    // angle = 180°, strongest at the right edge

// Custom band: full strength until 30% of the extent, sharp from 90% on
val gradient = ProgressiveBlur.Top.copy(startFraction = 0.3f, endFraction = 0.9f)

// Custom falloff: pronounced radius change early in the band, long soft tail after
val frontLoaded = ProgressiveBlur.Top.copy(curve = 0.5f)

colors and noiseCoefficient work the same way as in textureBlur, but they apply to the blurred region only and fade out together with the blur — the clear end blends seamlessly into the surrounding content instead of showing a tinted edge. Note that progressive blur defaults noiseCoefficient to 0f (disabled): grain riding a blur gradient is more visible than grain on a uniform blur.

Performance

On top of the downscaled multi-level blur, the pixel-sharp clear end adds a full-resolution overlay pass per frame — more GPU bandwidth than textureBlur over the same area. Prefer progressive blur for bars and edge bands rather than large fills.

TIP

Inside a custom drawBackdrop pipeline, progressiveTextureBlurEffect(...) runs the same preset chain in the effects block. Pair it with drawBackdrop's progressiveGradient parameter (same gradient) so the clear end stays genuinely sharp — Modifier.progressiveTextureBlur wires both together.

Advanced Usage

Independent X/Y Blur Radii

Apply different blur strengths for horizontal and vertical axes:

kotlin
Box(
    modifier = Modifier
        .textureBlur(
            backdrop = backdrop,
            shape = RoundedCornerShape(16.dp),
            blurRadiusX = 100f,
            blurRadiusY = 20f
        )
) {
    // Content with directional blur
}

Foreground Blur (Content Masking)

Use contentBlendMode to create a foreground blur effect where the content's alpha channel masks the blur:

kotlin
import androidx.compose.ui.graphics.BlendMode as ComposeBlendMode

Text(
    text = "Frosted Text",
    style = COUITheme.textStyles.title1,
    modifier = Modifier
        .textureBlur(
            backdrop = backdrop,
            shape = RectangleShape,
            blurRadius = 150f,
            contentBlendMode = ComposeBlendMode.DstIn // Content alpha masks the blur
        )
)

Noise Dithering

The noiseCoefficient parameter controls anti-banding noise applied to the blur result. The default value is 0.0045f. Set to 0f to disable:

kotlin
Box(
    modifier = Modifier
        .textureBlur(
            backdrop = backdrop,
            shape = RoundedCornerShape(16.dp),
            noiseCoefficient = 0f // Disable noise dithering
        )
)

Low-Level API: drawBackdrop

For full control over the effect pipeline, use Modifier.drawBackdrop() with BackdropEffectScope:

kotlin
import io.github.suqi8.coui.kmp.blur.drawBackdrop

Box(
    modifier = Modifier
        .drawBackdrop(
            backdrop = backdrop,
            shape = { RoundedCornerShape(16.dp) },
            effects = {
                // Apply a Blur
                blur(radius = 60f)
                // Adjust colors
                colorControls(
                    brightness = 0.05f,
                    contrast = 1.1f,
                    saturation = 1.3f
                )
            }
        )
) {
    // Content
}

BackdropEffectScope Extensions

ExtensionDescription
blur(radius, edgeTreatment)Applies a Blur
colorFilter(colorFilter)Applies a ColorFilter
colorControls(brightness, contrast, saturation)Adjusts brightness, contrast, and saturation
effect(effect)Chains an arbitrary RenderEffect
runtimeShaderEffect(key, shaderString, uniformShaderName, block)Applies a custom AGSL/SkSL runtime shader

Pixel-space uniforms must be scaled by downscaleFactor

When runtimeShaderEffect is chained after blur (or any other effect that raises downscaleFactor), the backdrop layer is recorded at 1 / downscaleFactor resolution and the shader receives coord values in the downscaled layer's pixel space. Any uniform that describes a pixel-space distance — size, padding/offset, corner radii, refraction band, etc. — must be divided by downscaleFactor inside block, otherwise samples land outside the layer bounds and return transparent black.

BackdropEffectScope Properties

PropertyTypeDescription
sizeSizeCurrent render size
layoutDirectionLayoutDirectionCurrent layout direction
shapeShapeCurrent clip shape
paddingFloatExtra padding for blur overflow
renderEffectRenderEffect?Accumulated effect chain
downscaleFactorIntDownsampling factor (1, 2, 4, 8, 16)
noiseCoefficientFloatNoise dithering coefficient

Properties

textureBlur / textureEffect Parameters

Parameter NameTypeDescriptionDefault ValueRequired
backdropBackdropThe backdrop providing background content to blur-Yes
shapeShapeShape for the blur region clipping-Yes
blurRadiusFloatBlur radius in dp, internally converted to pixels using display density. Clamped to [0, 150]20fNo
blurRadiusXFloatHorizontal blur radius in dp (independent radii overload)-Yes*
blurRadiusYFloatVertical blur radius in dp (independent radii overload)-Yes*
noiseCoefficientFloatNoise dithering coefficient for anti-banding, 0 disables0.0045fNo
colorsBlurColorsColor adjustments and blend layers applied after blurBlurColors()No
highlightHighlight?Optional edge highlight painted on top of the content. null skips drawingnullNo
contentBlendModeBlendMode?Blend mode for compositing content over the blurnullNo
enabledBooleanWhether blur is active, when false the effect is skipped and content draws normallytrueNo

* Required only in the independent radii overload.

progressiveTextureBlur Parameters

Parameter NameTypeDescriptionDefault ValueRequired
backdropBackdropThe backdrop providing background content to blur-Yes
shapeShapeShape for the blur region clipping-Yes
blurRadiusFloatBlur radius in dp at full strength. Clamped to [0, 150]20fNo
blurRadiusXFloatHorizontal blur radius in dp at full strength (independent radii overload)-Yes*
blurRadiusYFloatVertical blur radius in dp at full strength (independent radii overload)-Yes*
gradientProgressiveBlurDirection and band controlling where the blur is full vs zeroProgressiveBlur.TopNo
noiseCoefficientFloatNoise dithering coefficient for anti-banding, 0 disables0fNo
colorsBlurColorsColor adjustments and blend layers, fading out with the blurBlurColors()No
highlightHighlight?Optional edge highlight painted on top of the content. null skips drawingnullNo
contentBlendModeBlendModeBlend mode for compositing content over the blurSrcOverNo
enabledBooleanWhether blur is active, when false the effect is skipped and content draws normallytrueNo

* Required only in the independent radii overload.

ProgressiveBlur Properties

Property NameTypeDescriptionDefault Value
angleFloatGradient direction in degrees, clockwise from the +X axis: 0 fades left→right, 90 top→bottom, 180 right→left, 270 bottom→top90f
startFractionFloatFraction in [0, 1] of the projected extent along angle where the blur is at full strength0f
endFractionFloatFraction in [0, 1] where the blur fades to zero; may be smaller than startFraction to reverse the ramp, must differ from it1f
curveFloatPower-curve exponent reshaping the falloff between the fixed endpoints: < 1 concentrates the radius change toward the strong end, > 1 toward the clear end. Must be positive1f

Presets: ProgressiveBlur.Top / Bottom / Left / Right cover the four edge fades.

BlurColors Properties

Property NameTypeDescriptionDefault Value
blendColorsList<BlendColorEntry>Colors blended over the blurred backdrop, drawn in orderemptyList()
brightnessFloatBrightness adjustment in range [-1, 1]0f
contrastFloatContrast multiplier1f
saturationFloatSaturation multiplier1f

BlurDefaults

ConstantTypeDescriptionValue
BlurRadiusFloatDefault blur radius in dp20f
NoiseCoefficientFloatDefault noise dithering coefficient0.0045f
MaxBlurRadiusFloatMaximum allowed blur radius in dp150f
MethodReturn TypeDescription
blurColors()BlurColorsCreates a remembered BlurColors instance

Edge Highlight

A Highlight paints a thin glassy edge with two directional lights along a rounded shape. It is drawn through the highlight parameter on Modifier.textureBlur / Modifier.textureEffect (constant case) or on Modifier.drawBackdrop (reactive case, sharing the BackdropEffectScope). Combined with the blurred backdrop this produces a "lit edge over blurred backdrop" look.

With textureBlur (constant)

kotlin
import androidx.compose.foundation.shape.RoundedCornerShape
import io.github.suqi8.coui.kmp.blur.highlight.Highlight

Box(
    modifier = Modifier
        .size(200.dp, 100.dp)
        .textureBlur(
            backdrop = backdrop,
            shape = RoundedCornerShape(24.dp),
            highlight = Highlight.GlassStrokeMiddleLight,
        ),
)

With drawBackdrop (reactive)

The highlight lambda runs inside the same BackdropEffectScope as effects, so its return value can change with state (e.g. press progress) and pick up the current size / shape automatically.

kotlin
import io.github.suqi8.coui.kmp.blur.drawBackdrop
import io.github.suqi8.coui.kmp.blur.blur
import io.github.suqi8.coui.kmp.blur.highlight.Highlight

Box(
    modifier = Modifier
        .size(200.dp, 100.dp)
        .drawBackdrop(
            backdrop = backdrop,
            shape = { RoundedCornerShape(24.dp) },
            effects = { blur(20.dp.toPx()) },
            highlight = { Highlight.GlassStrokeMiddleLight.copy(alpha = pressProgress) },
        ),
)

Pass null (or a Highlight with width = 0.dp) to disable.

WARNING

Edge highlight requires isRuntimeShaderSupported(). On unsupported platforms or API levels, the highlight is skipped silently.

Built-in Tokens

Six presets are provided. Pick by card size and theme:

TokeninnerBlurRadiusVisual
Highlight.GlassStrokeBigLight3.5 dpThickest, softest halo — large light-mode cards
Highlight.GlassStrokeMiddleLight2.8 dpStandard light-mode card (default)
Highlight.GlassStrokeSmallLight2.6 dpCompact, sharper light-mode card
Highlight.GlassStrokeBigDark1.7 dpThinnest halo — large dark-mode card
Highlight.GlassStrokeMiddleDark2.0 dpStandard dark-mode card
Highlight.GlassStrokeSmallDark2.3 dpCompact, sharpest dark-mode card

Highlight.Default aliases GlassStrokeMiddleLight.

Custom BloomStroke

Override individual fields on a token, or build a BloomStroke from scratch:

kotlin
import io.github.suqi8.coui.kmp.blur.highlight.BloomStroke
import io.github.suqi8.coui.kmp.blur.highlight.LightPosition
import io.github.suqi8.coui.kmp.blur.highlight.LightSource

val custom = Highlight(
    width = 1.dp,
    alpha = 0.8f,
    style = BloomStroke(
        color = Color.White.copy(alpha = 0.05f),
        innerBlurRadius = 3.dp,
        primaryLight = LightSource(
            position = LightPosition(0.5f, 0.4f, -0.5f),
            intensity = 0.4f,
        ),
        secondaryLight = LightSource(
            position = LightPosition(0.5f, 0.85f, -0.5f),
            intensity = 0.3f,
        ),
    ),
)

LightPosition is in normalized UV with the reference origin at (0.5, 0.7, 0). The shader normalizes (x − 0.5, y − 0.7, z) into a 3D unit direction. Negative z places the light behind the surface plane, illuminating the inward-facing edge — all built-in tokens use z < 0.

The two lights are restricted to opposite hemispheres by the shader: primaryLight lights the upper half of the rounded rect, secondaryLight lights the lower half. Position y < 0.7 biases a light upward; y > 0.7 biases it downward.

Sensor-driven Tilt Parallax

rememberTiltLight shifts a base position in real time using the device rotation sensor. On Android, this produces a parallax-like edge that follows device tilt; on Desktop / iOS / macOS / Web the tilt is always zero, so the lights stay anchored.

kotlin
import io.github.suqi8.coui.kmp.blur.highlight.rememberTiltLight

val baseStyle = Highlight.GlassStrokeMiddleLight.style as BloomStroke

val tiltPrimary = rememberTiltLight(
    basePosition = baseStyle.primaryLight.position,
    intensity = baseStyle.primaryLight.intensity,
    sensitivity = 0.15f,
)
val tiltSecondary = rememberTiltLight(
    basePosition = baseStyle.secondaryLight.position,
    intensity = baseStyle.secondaryLight.intensity,
    sensitivity = 0.12f,
)

val highlight = Highlight.GlassStrokeMiddleLight.copy(
    style = baseStyle.copy(
        primaryLight = tiltPrimary,
        secondaryLight = tiltSecondary,
    ),
)

sensitivity controls how far the UV position shifts per radian of tilt — 0.1f shifts the light by 10% of the bounds at 1 rad. Higher values amplify the parallax.

Edge Highlight Properties

Highlight

PropertyTypeDescriptionDefault
widthDpStroke band width painted with style.color0.8.dp
alphaFloatOverall opacity multiplier, range [0, 1]1f
styleHighlightStyleShading model (typically a BloomStroke)HighlightStyle.Default

BloomStroke

PropertyTypeDescriptionDefault
colorColorBase color of the stroke band; alpha drives stroke brightnessWhite.copy(alpha = 0.05f)
blendModeBlendModeCompositing mode for the highlight overlayBlendMode.Plus
innerBlurRadiusDpDepth the lighting reaches inward from the rounded edge — controls halo softness and thickness2.8.dp
primaryLightLightSourceUpper-hemisphere lightLightPosition(0.5f, 0.5f, -0.5f), intensity 0.4
secondaryLightLightSourceLower-hemisphere lightLightPosition(0.5f, 0.8f, -0.5f), intensity 0.25

LightSource

PropertyTypeDescriptionDefault
positionLightPositionUV position of the light-
colorColorLight color (color alpha is folded into the contribution scale)White
intensityFloatBrightness scalar, ≥ 01f

LightPosition

PropertyTypeDescription
xFloatNormalized UV x (reference origin: 0.5)
yFloatNormalized UV y (reference origin: 0.7)
zFloatSigned depth; negative places the light behind the surface

rememberTiltLight Parameters

ParameterTypeDescriptionDefault
basePositionLightPositionPosition at zero tilt-
colorColorLight colorWhite
intensityFloatBrightness scalar1f
sensitivityFloatUV offset applied per radian of tilt0.1f

Changelog

Released under the Apache-2.0 License