Skip to content

Interaction

Press

Opt-in, as Apple makes it. The response is strong enough that applying it to everything would be noise.

Modifier.glassEffect(Glass.Regular.interactive(), RoundedCornerShape(30.dp))

// or choose the response explicitly
Modifier.glassEffect(Glass.Regular.interactive(response = GlassInteraction.Default), RoundedCornerShape(30.dp))

interactive() uses GlassInteraction.Calm unless told otherwise. The table below describes GlassInteraction.Default, the expressive response.

Apple describes an interactive element as one that "scales, bounces and shimmers", where the material "illuminates from within" starting under the fingertip and spreading to nearby glass, with "gel-like flexibility… as it moves in tandem with your interaction".

Three channels do that, and they deliberately do not share a spring:

Channel Down Up
scale overshoots to 1.04, one visible bounce settles calmly, no bounce
glow rises in 90ms falls over 260ms
touch point tracks the finger continuously freezes where it left, then fades

The separation is the point. The scale is what the hand feels, so it overshoots; a bouncy release reads as a rendering glitch. And freezing the touch point on release is what stops the highlight snapping back to the middle of the panel as your finger lifts.

The glow is a Gaussian of σ = the panel's short edge, worth about a sixth of a stop at the peak. That number is measured against a device; anything near 0.2 reads as a camera flash rather than as glass responding to a touch. It drags a small magnification of the backdrop with it.

interactive() observes but does not consume the gesture, so an element can be interactive glass and a normal clickable without the two fighting.

Press and pull

The GlassInteraction.Pullable preset (new in 0.2.0) adds per-side press growth and continuous drag deformation. The target becomes progressively resistant before it enters the animation, so a distant pointer cannot store unlimited hidden travel. Stretch and squeeze use the support widths along and across the pull; wide panels therefore do not lose a large fraction of their width just because they are pulled along their short axis. The material now deforms around a fixed centre. Ordinary labels, icons, layout and hit targets stay still. Wide bars keep their spine horizontal; round controls retain diagonal stretch. This anchored policy supersedes the earlier free-body translation extrapolation. See generic interaction for complete integration examples and gesture ownership.

Allow the glass to draw beyond its resting layout. In particular, Compose shadows clip by default when elevation is positive. Use shadow(..., clip = false) on an interactive glass ancestor; a clipping shadow can cut off the expanded material during a pull.

These authored input limits and the rectangular extrapolation do not establish full iOS parity. See implementation status for the current evidence.

Large surfaces now receive a subtler drag response than small controls. R14 preserves touch expansion and reduces only drag strain, with a smooth size transition and a 2dp total extension cap on surfaces at least 160dp long. See the size policy.

When something else owns the gesture

A panel that is also the thing you touch can watch its own pointer, and interactive() alone is enough. A panel that a parent manipulates cannot — a selection indicator inside a tab bar sits beneath the tab buttons and never sees a touch, so it can be moved but never lights up, which is exactly the half-finished feel of an indicator that animates without responding.

The answer is a GlassPressSource driven from wherever the gesture actually lives. This is how GlassTabBar drives its selection lens. Since 18 October the pressSource parameter that hands a source to a piece of glass is internal, so no public modifier accepts one at present; the pattern below is what the bar does inside.

val press = rememberGlassPressSource()   // handed to the lens's glass inside GlassTabBar

Box(Modifier.fillMaxSize().pointerInput(Unit) {
    awaitEachGesture {
        val down = awaitFirstDown(requireUnconsumed = false)
        val grabbed = awaitHorizontalTouchSlopOrCancellation(down.id) { c, _ -> c.consume() }
            ?: return@awaitEachGesture           // a tap still reaches the buttons
        press.press(toPanelLocal(grabbed.position))
        horizontalDrag(grabbed.id) { change ->
            press.press(toPanelLocal(change.position))
            change.consume()
        }
        press.release()
    }
})

Two details worth copying. Claim nothing until the pointer crosses the touch slop, so a tap stays a tap and only a drag becomes a drag. And convert into the panel's local pixels, not the gesture owner's — the glow is positioned in the panel's own space.

Content inside the glass

If what you want is a tab bar, use GlassTabBar; it does everything in this section and the next for you. What follows is how it works.

Content normally sits on a panel and is left alone: labels on a button, icons on a bar. Some content is what the glass is looking at, and has to be bent with everything else it shows. The clearest case is Apple's tab bar. The selection indicator there is a lens above the tabs, and a tab looks selected because you are seeing it through the lens — slide the lens halfway across a symbol and the symbol is half one colour and half the other, with the rim compressing and colour-splitting its edges as it passes.

The refractContent option of the lens's glass does that (internal since 18 October; GlassTabBar uses it). The content gets its own pass through the same distance field, bevel and Snell deviation the backdrop goes through, and is drawn over the material: bent at the rim, split into colour fringes, clipped to the shape. It is deliberately not recorded into the backdrop, which would also tint and dim it — glass dims what is behind it, not what is printed inside it. So a lens can carry a copy of a whole row and show only the part it is over, in full colour:

The arrangement, as GlassTabBar builds it (the Modifier.liquidGlass call is the since-removed public form, shown for the shape of the layout):

// The lens moves; the row inside it is pinned to the bar's origin so it does not.
Box(
    Modifier
        .offset { IntOffset(lensLeft, lensTop) }
        .size(lensWidth, lensHeight)
        .liquidGlass(glass, CircleShape, LensStyle, pressSource = press, refractContent = true),
) {
    Box(Modifier.layout { m, c ->
        val p = m.measure(Constraints.fixed(barWidth, barHeight))
        layout(c.maxWidth, c.maxHeight) { p.place(-lensLeft, -lensTop) }
    }) {
        TabRow(ink = accent)          // the same row the bar draws, in the selected colour
    }
}

Keep the two copies geometrically identical — same glyph, same stroke, same weight — or a tab half-covered by the lens will not line up with itself.

A lens that sits on other glass looks through it. On iOS the lens is above the bar, so what reaches the eye has already been through the bar's frost: the lens's interior reads as the bar does, and its rim bends the bar's own hairline. GlassTabBar records the bar into a second layer and composites it over the backdrop before the lens's shader runs. Without it the lens samples the app's content directly and punches a clear hole through the bar, which is the one thing the reference never does.

And the element must be able to outgrow its parent. A held lens is taller than the bar it belongs to, and Modifier.size is coerced into the incoming constraints, so it would be silently clamped to the parent's height while its width — under the limit — came out right. Use Modifier.requiredSize.

Where the shader cannot run, the content is drawn on top as it always was; a host that relies on the lens to colour its content should colour it directly on that path instead (LiquidGlassSupport.hasShaders).

Spill onto neighbours

Apple's glow "spreads onto any Liquid Glass elements nearby". Earlier versions of this page said a container did this; it does not. Pieces inside a GlassEffectContainer are drawn by the container with one Glass, and their own interactive() is ignored, so a container does not respond to touch at present.

Reduce Motion

Apple's guidance is that Reduce Motion "decreases the intensity of some effects and disables any elastic properties". GlassInteraction.ReducedMotion does exactly that: no scale, no bounce, no gel, and the glow at half strength. The feedback survives; the elasticity does not.

val glass = Glass.Regular.interactive(
    response = if (reduceMotion) GlassInteraction.ReducedMotion else GlassInteraction.Calm,
)

Materialize

Apple: "Instead of fading, Liquid Glass objects materialize in and out by gradually modulating the light bending and lensing", and the UIKit guidance is to prefer setting the effect over setting alpha.

GlassTabBar takes materialize for this; drive it rather than alpha:

val present by animateFloatAsState(
    targetValue = if (visible) 1f else 0f,
    animationSpec = if (visible) GlassMotion.MaterializeIn else GlassMotion.MaterializeOut,
)

GlassTabBar(..., materialize = present)

glassEffect does not take materialize at present (the parameter is internal since 18 October). Glass.Identity switches a piece of glass off without a transition; an ancestor's alpha does not fade glass at all (see Limitations).

One uniform scales displacement, scatter, tint, mirror, specular and inner shadow together. At 0 the shader returns the backdrop untouched, so the element is gone without alpha being involved — Apple's mechanism implemented literally rather than approximated.

The visible difference: a fading element is a translucent picture of glass, and gets fainter. A materializing one stops bending light, and the last thing to go is the edge.

Morphing

An element changing role is not one shape growing. Apple states the material's characteristics change with size — deeper shadows, more pronounced lensing, softer scattering — so a 48dp button becoming a 280dp menu is a Regular becoming something nearer Thick, and the material has to travel with the geometry.

val style = animateGlassStyle(
    start = GlassStyle.Regular,
    stop = GlassStyle.Thick,
    morphed = expanded,
)

Modifier
    .size(animatedSize)                       // same clock
    .glassEffect(Glass(style), shape)

Use the same spring for both, so the material cannot desynchronise from the shape it belongs to. GlassMotion.Morph is a reasonable default: settles in about 420ms with no visible overshoot on a large surface.

The observable failure, if you skip this: hold the edge parameters constant while the shape grows and the rim stays exactly as wide as it was, which reads as a picture of glass being stretched rather than as glass getting bigger.

uScale needs no help — it is derived from the measured size every frame and follows the geometry on its own.

Decisions do not interpolate. invertsWithBackdrop and fallbackSurface snap at the midpoint, because a half-inverted element is not a state anything wants to be in.

Merging

GlassEffectContainer renders the glass inside it as one body. Their distance fields are combined with a smooth minimum, so pieces within spacing of each other grow a neck and fuse, then separate as they move apart.

GlassEffectContainer(spacing = 20.dp) {
    Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
        Box(Modifier.size(96.dp).glassEffect(shape = CircleShape))
        Box(Modifier.size(96.dp).glassEffect(shape = CircleShape))
    }
}

Nothing animates the merge. It is a consequence of the geometry, which is why it stays correct at any speed, in any direction, under interruption, and at any frame rate — none of which is true of an alpha crossfade or a keyframed morph. Move the pieces with whatever spring your layout uses and the neck grows and snaps on its own.

Capped at eight pieces: SkSL needs an unrollable loop, and every piece is evaluated at every pixel, which is exactly what lets the fields interact.

Keep spacing at or below your layout's own spacing, or pieces will blend at rest. When it is unspecified it is LiquidGlassTheme's containerSpacing, 20 dp by default.

Give pieces a Modifier.glassEffectID(id, namespace), after glassEffect, and a piece that appears where another with the same id was grows out of the old outline instead of popping in:

val ns = rememberGlassNamespace()
GlassEffectContainer {
    Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
        Icon(..., Modifier.size(44.dp).glassEffect().glassEffectID("edit", ns))
        if (expanded) Icon(..., Modifier.size(44.dp).glassEffect().glassEffectID("share", ns))
    }
}