Sitemap
Alex Volkov

Alex Volkov

Principal Software Engineer with passion to shaders

The Glass Is in the Edges

Highlights, Fresnel and a whole component set from one AGSL shader

13 min readOct 30, 2026

--

Press enter or click to view image in full size

Hi everyone!

Glass is everywhere in UI right now, and most of the time it comes together with blur, refraction and all the “liquid” things. Those are fun, but they hide the simpler question I wanted to answer first. Take a piece of glass with nothing magical behind it — just a calm background. What makes the eye read it as glass and not as a gray rounded rectangle?

My answer after a few evenings with a lot of references: almost nothing in the middle and everything at the edges. And if that’s true, then glass is a set of rules, and rules fit nicely into one shader. To check it, I built a small glass UI kit from one modifier — a tab bar, a search field, a segmented control, cards, round buttons and a switch, in a day and a night mood.

Press enter or click to view image in full size
the same set in two moods: every element is one Modifier.glass with different parameters

As always, every step of this tutorial is a separate commit in the article/glass-highlights branch on my GitHub

What glass is

I’ve just finished two articles about neumorphism, so a short comparison is hard to avoid — but only a short one, because these are two different ideas. In neumorphism, the element is made of the background: same material, same color, and two soft shadows tell you where the bump is. Glass is the opposite in every way. It’s a separate object hovering above the background, and you see the background through it. So the body can’t carry the shape — it’s almost invisible by definition. The shape has to come from light.

Press enter or click to view image in full size
two different ideas: a bump made of the surface, and a plate floating above it

If you look at real glass, or at good glass UI, the light always does the same few things. These are the rules I ended up with, and the rest of the article is just these rules turned into code, one per step:

The body is almost nothing. A thin tinted filter over what’s behind it. The edge is a line of light. Glass reflects most strongly when you look at it at a grazing angle, and on a flat plate that only happens on its thin rounded edge. Two highlights. The edge facing the light mirrors it. The opposite edge shines too, because the light went through the glass and comes back out there. Thickness. A little of that light lingers inside, near the edges. It floats. One soft shadow, a bit weaker under the glass itself.

I deliberately leave out refraction and blurring the backdrop. That’s a separate, much heavier topic, and everything here works without it.

A body you can barely see

The modifier draws behind the content with a ShaderBrush , exactly like a background would. The shape is the usual signed distance of a rounded box: negative inside, zero on the edge, positive outside. The body is just a tint with 10% alpha:

fun Modifier.glass(
cornerRadius: Dp,
tint: Color = Color.White,
tintAlpha: Float = 0.10f, // how much of the body we see: glass is mostly transparent
): Modifier = composed {
val shader = remember { RuntimeShader(glassShader) }
val brush = remember { ShaderBrush(shader) }
drawBehind {
shader.setFloatUniform("size", size.width, size.height)
shader.setFloatUniform("radius", min(cornerRadius.toPx(), size.minDimension / 2f))
shader.setColorUniform("tint", tint.toArgb())
shader.setFloatUniform("tintAlpha", tintAlpha)
drawRect(brush)
}
}
// the shader: sdRoundBox and Shape(p) are the usual rounded box distance
half4 main(float2 p) {
float d = Shape(p);
float inside = 1.0 - smoothstep(-0.75, 0.75, d);
// the body: a thin tinted filter over whatever is behind it (premultiplied)
float a = tintAlpha * inside;
return half4(tint.rgb * a, a);
}

The background behind the elements is a plain Compose gradient with a few big soft spots of light that drift very slowly — no shader there. Glass needs something to be transparent over, otherwise there is nothing to show through it. And the usage is boring on purpose:

Box(
modifier = Modifier
.size(width = 200.dp, height = 68.dp)
.glass(cornerRadius = 34.dp),
contentAlignment = Alignment.Center
) {
Text("Sign In", fontSize = 22.sp, fontWeight = FontWeight.Medium, color = Ink)
}
Press enter or click to view image in full size
step 1: a tinted filter. Readable, but nobody would call it glass

My first glass was a balloon

Now the edges. My first attempt was the obvious one: give the element a rounded bevel, compute its normal, and light it. I made the bevel 14dp wide, because that’s how it looked on a big mockup — and got the left image below. It’s shiny, it’s smooth… and it looks like a balloon. The highlight sits a couple of millimeters away from the edge, on the curve of the bevel. Real glass doesn’t do that.

Press enter or click to view image in full size
left: my first attempt with a 14dp bevel, right: a 2.5dp edge with the same lighting idea

The reason is simple once you see it. A glass plate in UI is flat and thin. The rounded part of its edge is tiny — a couple of dp at most — so whatever happens on that curve happens within a few pixels. A wide bevel turns the plate into a pillow, and the eye immediately reads it as something soft and inflated. So the first real rule of glass is: keep the edge thin.

The normal of the edge comes from the distance itself. Its direction in the screen plane is the gradient of Shape , and how steep it is depends on how close we are to the edge, as if the edge were a quarter of a circle edge pixels wide. I also need a proper way to lay light over the glass, because a premultiplied color can’t have more light than alpha:

// outward direction of the edge: the gradient of the distance
float2 EdgeDir(float2 p) {
float2 g = float2(Shape(p + float2(1.0, 0.0)) - Shape(p - float2(1.0, 0.0)),
Shape(p + float2(0.0, 1.0)) - Shape(p - float2(0.0, 1.0)));
return normalize(g + 1e-4);
}
// light laid over the glass: a premultiplied "over", with its own alpha.
// (rgb can't be bigger than alpha in a premultiplied color, so light needs alpha too)
half4 Over(half4 dst, half3 color, float k) {
k = clamp(k, 0.0, 1.0);
return half4(color * k + dst.rgb * (1.0 - k), k + dst.a * (1.0 - k));
}

And then Fresnel. When you look at glass straight on, it reflects only a few percent of the light. At a grazing angle it becomes almost a mirror. Christophe Schlick’s approximation says the reflectance grows like (1 − cos θ)⁵ , and for a surface facing us, cos θ is just n.z . On a flat plate n.z is 1 everywhere except the edge, so Fresnel draws exactly what we want: a line of light all around.

// the edge of a glass plate is rounded, but it's thin: a couple of dp
float e = clamp(-d / edge, 0.0, 1.0); // 0 on the edge, 1 where the flat top begins
float s = 1.0 - e; // how steep the surface is here
float3 n = normalize(float3(EdgeDir(p) * s, sqrt(max(1.0 - s * s, 0.0))));
// Fresnel (after Schlick): the flatter we look at glass, the more of a mirror it becomes.
// On a flat plate that only happens on the edge, so this is a thin line all around.
// (Schlick uses the power of 5; on a 2dp edge that's thinner than a pixel, so I use 2)
float fresnel = pow(1.0 - n.z, 2.0);
// for now the edge shines the same all around
float rim = fresnel * 0.6;
// the body: a thin tinted filter over whatever is behind it (premultiplied)
float a = tintAlpha * inside;
half4 glass = half4(tint.rgb * a, a);
// the reflected light lies on top of it
glass = Over(glass, shine.rgb, (rim) * inside);
return glass;
Press enter or click to view image in full size
the Fresnel term across the edge: a wide bevel makes a bump, a thin edge makes a line
Press enter or click to view image in full size
step 2: a thin line of light all around the edge

Two highlights

A line of the same brightness everywhere looks like a stroke from a design tool. Real glass has a direction: the light comes from somewhere. So each piece of the edge needs to know where it looks. That’s one dot product between the edge direction and the light direction, and it splits the edge into three parts.

Press enter or click to view image in full size
the same dot product decides which part of the edge shines, and how much

Where facing is positive, the edge looks at the light and mirrors it — the key highlight, the brightest thing on the whole element. Where it’s negative, we are on the far edge. Light that entered the plate travels through it, hits that edge from the inside and comes out — so the far edge shines too, a bit weaker. On the sides, there is only the Fresnel line. And one more thing for the flat top: Fresnel never goes completely to zero, so the top is a very weak mirror too. It reflects a bright “sky” on the side of the light.

// which way this piece of the edge looks: towards the light, or away from it.
float facing = dot(EdgeDir(p), lightDir);
// the edge facing the light mirrors it: that's the key highlight. The opposite edge
// shines too, weaker: light went through the plate and comes out there
float key = pow(max(facing, 0.0), 1.5);
float back = pow(max(-facing, 0.0), 1.5);
float rim = fresnel * (0.45 + 1.4 * key + 0.9 * back);
// the flat top is a weak mirror too (Fresnel never goes to zero): it reflects a bright sky
// on the side of the light and fades to nothing on the other side
float along = dot((p - size * 0.5) / max(size.x, size.y), -lightDir); // -0.5..0.5
float sky = smoothstep(0.1, -0.5, along) * 0.07;
// ...
glass = Over(glass, shine.rgb, (sky + rim) * inside);

The light direction is one constant for every piece of glass on the screen. That’s important: all elements should agree on where the light is, otherwise the screen looks like a collage.

// one light for every piece of glass: top left, a bit more from above
val GlassLight = Offset(-0.6f, -0.8f)
Press enter or click to view image in full size
left: step 3, right: the same light alone, written without the body (a temporary debug line)

Thickness and lift

Two small things finish the material. The first is thickness. Thick glass keeps a little of the light that got inside, and it shows up as a soft glow just inside the edges, mostly on the far side. That’s an exponential falloff of the same distance, weighted by how much the edge faces away from the light.

Get Alex Volkov’s stories in your inbox

Join Medium for free to get updates from this writer.

The second is lift. Glass is a separate object above the backdrop, so it casts a shadow — a soft copy of the shape, shifted away from the light. Since the body is transparent, the shadow is visible through the glass itself, so I make it weaker there: light goes through glass.

// thick glass: light that got inside comes back out near the edges, mostly on the far side
float band = exp(d / thickness) * (0.05 + 0.2 * pow(max(-facing, 0.0), 2.0));
// ...
glass = Over(glass, shine.rgb, (sky + rim + band) * inside);
// glass is a separate object above the backdrop: one soft shadow, away from the light.
// It's weaker under the glass itself, because the light goes through it
float shadow = Soft(Shape(p + lightDir * shadowShift), shadowBlur) * 0.32;
shadow *= (1.0 - inside * 0.7);
return glass + half4(0.0, 0.0, 0.0, shadow) * (1.0 - glass.a);

On the Kotlin side, lift sets both the shift and the blur of the shadow, and the shader draws a bit outside of the element to have room for it:

lift: Dp = 10.dp,           // how high the glass floats: moves and softens its shadow
// ...
shader.setFloatUniform("shadowShift", lift.toPx())
shader.setFloatUniform("shadowBlur", lift.toPx() * 2.2f)
// the shadow lies outside of the element
val margin = lift.toPx() * 3.4f + 2f
drawRect(
brush = brush,
topLeft = Offset(-margin, -margin),
size = Size(size.width + margin * 2, size.height + margin * 2)
)
Press enter or click to view image in full size
left: step 3, right: step 4 — the glow inside the far edges and the shadow underneath

One shader, a whole set

That’s the whole material. Now the part I like most: building a UI kit out of it without writing another line of shader. It turns out that three roles cover everything.

Raised glass is what we have. Pressed glass is the same piece coming down to the surface: its edge catches less light, its shadow tucks in under it, and it shrinks a tiny bit. And a well — the darker pocket that marks the selected tab or segment — is the same edge turned inside out. For that, the shader gets one more parameter, depth . It multiplies the edge direction, so with depth = -1 the lit and the far edges swap places, exactly like in a dent. A well also gets a shaded wall on the side of the light and no shadow of its own:

// which way this piece of the edge looks: towards the light, or away from it.
// A well (depth < 0) is the same edge turned inside out
float facing = dot(EdgeDir(p), lightDir) * depth;
// ...
band *= max(depth, 0.0);
// a well has a shaded wall on the side of the light, like any dent
float well = max(-depth, 0.0);
float wallShade = (1.0 - Soft(Shape(p + lightDir * thickness * 0.35), thickness * 0.4))
* 0.3 * well;
// ...
glass = Over(glass, half3(0.0), wallShade * inside);
glass = Over(glass, shine.rgb, (sky * max(depth, 0.0) + rim + band) * inside);
// ... and the shadow is multiplied by max(depth, 0.0): a well doesn't float

A pressable piece of glass just animates depth and lift :

val pressed by interaction.collectIsPressedAsState()
// pressed: the glass comes down to the surface, so the edge catches less light
// and the shadow tucks in under it
val depth by animateFloatAsState(if (pressed) 0.35f else 1f, tween(120), label = "depth")
val lift by animateDpAsState(if (pressed) 3.dp else 10.dp, tween(120), label = "lift")
Box(
modifier = modifier
.graphicsLayer {
val scale = 1f - (1f - depth) * 0.04f
scaleX = scale
scaleY = scale
}
.glass(cornerRadius, lift = lift, depth = depth)
.clickable(interaction, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
content = content
)
Press enter or click to view image in full size
the Sign In button at rest and pressed

And a well is a one-line modifier. The tab bar and the segmented control put it under the selected item and slide it with a spring:

fun Modifier.glassWell(cornerRadius: Dp) =
glass(cornerRadius, tint = Color.Black, tintAlpha = 0.16f, lift = 0.dp, depth = -1f)
// in the tab bar: the well slides under the selected tab
Box(
Modifier
.offset(x = x)
.width(tabWidth)
.fillMaxHeight()
.glassWell(cornerRadius = 32.dp)
)
Press enter or click to view image in full size
the wells slide under the selected tab and segment

With those three roles, every component is a recipe — the same modifier with different parameters:

  • Round button, Sign In: raised, pressable: depth 1 → 0.35, lift 10 → 3dp
  • Tab bar: raised capsule + a sliding well
  • Segmented control: raised, thicker glass (thickness = 22.dp) + a sliding well
  • Search field: raised, thin and close to the surface (thickness = 8.dp, lift = 6.dp)
  • Card: raised, thickness = 16.dp
  • Switch: a well for the track, a small raised plate for the knob
Press enter or click to view image in full size
the whole set: one modifier, one light

Night glass

The last check for any “set of rules”: does it survive a different mood? Glass on a dark background looks different — the body almost disappears and only the light is left. But the rules don’t change, only the colors do. So a mood is just a small data class, and the screen animates between two of them together with the backdrop:

// A mood of the glass: only colors. The rules (edge, Fresnel, two highlights, shadow) never change
@Immutable
data class GlassStyle(val tint: Color, val tintAlpha: Float, val shine: Color, val wellAlpha: Float)
val DayGlass = GlassStyle(Color.White, tintAlpha = 0.10f, shine = Color.White, wellAlpha = 0.16f)
// at night the body is barely there and the light is a little colder
val NightGlass = GlassStyle(
Color.White, tintAlpha = 0.05f, shine = Color(0xFFDCE4FF), wellAlpha = 0.32f
)
val LocalGlassStyle = staticCompositionLocalOf { DayGlass }
// ... and on the screen: the backdrop and the glass style move together
val t by animateFloatAsState(if (night) 1f else 0f, tween(durationMillis = 900), label = "mood")
CompositionLocalProvider(LocalGlassStyle provides lerp(DayGlass, NightGlass, t)) {
GlassBackdrop(lerp(TealMood, NightMood, t)) {
// ... the same components

The components read the style through a styledGlass wrapper, which passes the mood’s colors to the same Modifier.glass . The switch in the corner of the screen is built from the same pieces too: a well for the track and a small raised plate for the knob.

Press enter or click to view image in full size
the same components in the day and in the night mood

What’s left

So glass is really in the edges. The body does almost nothing, and all the work is done by a thin line of light whose brightness depends on two things: how flat we look at the surface (Fresnel) and which way the edge faces the light (one dot product). Turn the edge inside out, and you get a well. Change the colors, and you get another mood. The rest is layout.

The obvious next step is the backdrop itself: right now it shows through the glass perfectly sharp, and real glass bends and blurs what’s behind it. That’s a different story with a lot more machinery, and I wanted this one to stay about light.

Thank you for reading, and see you in the next one!

--

--

Written by Alex Volkov

Principal Software Engineer with passion to shaders