A Light Inside the Material
Colorful accents for neumorphic widgets in Jetpack Compose and AGSL
In my previous tutorial about neumorphism I left one problem open. Soft UI has almost no contrast, so the only state indicator on the whole screen was a tiny green LED on a toggle. For a switch that’s enough. But try to build a timer, a volume knob or a download bar this way, and the screen turns into gray fog where nothing is clearly on or off.
So I spent an evening scrolling through neumorphic shots on Dribbble, and the ones that really work all do the same thing. The material stays gray and calm. Color shows up in exactly one place per widget — a progress ring, a slider, a thin channel cut into the surface. And it never looks painted on. It glows, as if somebody put a light inside the material.
That’s what we’ll build: four widgets on one screen, all drawn by one new modifier. I’ll reuse Modifier.neumorphic from the first part for the cards and dishes, so soft shadows won’t be explained again. Everything new is about color.
Light, not paint
Before any code, it helps to decide what the color physically is. If it’s paint, it lies on top of the surface, has a hard edge, and doesn’t affect anything around it. That’s how a regular flat progress bar looks, and on soft material it looks cheap. You’ll see it in a minute — I built that version too.
The alternative is to treat the accent as a light source: a small neon tube lying at the bottom of a channel carved into the material. That one decision gives three rules, and the whole tutorial is just these rules turned into code:
- The color lives in a groove. A groove is a pressed shape, and we already know how to draw those.
- The fill is round. A tube has a highlight along its top and darker sides, so the eye reads it as volume.
- The light leaks out. The walls of the groove around the tube pick up its color.
And one more rule, which is more about taste: one accent per widget. Two neighboring hues are fine, a rainbow is not.
One groove for four widgets
If you look at the four widgets as shapes, there are only two of them. The timer is a full ring and the volume knob is 270° of a ring. The dimmer and the download bar are capsules, one tall and one wide. So the modifier needs to know the shape, the thickness, and for rings, which part of the circle to take:
// A widget track is a groove carved into the material: a ring (or a part of it) or a capsule
enum class TrackShape { Ring, Bar }
fun Modifier.accentTrack(
shape: TrackShape,
thickness: Dp = 18.dp,
startDeg: Float = 0f, // ring only: where the track starts, 0 = top, clockwise
sweepDeg: Float = 360f, // ring only: how much of the circle the track takes
): Modifier = composed {
val light = LocalLightDirection.current
val palette = LocalNeuPalette.current
val shader = remember { RuntimeShader(accentShader) }
val brush = remember { ShaderBrush(shader) }
drawBehind {
val l = light.value
val c = palette.value
shader.setFloatUniform("size", size.width, size.height)
shader.setFloatUniform("kind", if (shape == TrackShape.Ring) 0f else 1f)
shader.setFloatUniform("thickness", thickness.toPx())
shader.setFloatUniform("startAngle", Math.toRadians(startDeg.toDouble()).toFloat())
shader.setFloatUniform("sweep", Math.toRadians(sweepDeg.toDouble()).toFloat())
// ... lightDir, shadowShift (3dp), shadowBlur (5dp) and the three palette colors
drawRect(brush)
}
}Note that there is no graphicsLayer here: like the soft shadows, the track draws itself behind the content with a ShaderBrush, so the time and the numbers sit on top of it.
A capsule is the classic distance to a line segment minus a radius. The ring is the more interesting one, because I need a part of it, with round ends. The trick is to look at the angle of the point first. If the point is within the arc’s angular range, the closest point of the arc is straight towards the center or away from it, so the distance is just |length(q) − R| − r. If it’s outside the range, the closest point of the arc is one of its two ends, so we take the distance to the nearest end. And distance to a point minus r is a circle — round caps, without writing a single line for them.
// angle of a point around the centre: 0 at the top, growing clockwise
float Angle(float2 q) {
float a = atan(q.x, -q.y);
return a < 0.0 ? a + TAU : a;
}
// a ring of radius R and half-width r, cut to [start, start + len]
float ArcSdf(float2 q, float R, float r, float start, float len) {
float u = mod(Angle(q) - start + TAU, TAU);
if (u <= len) return abs(length(q) - R) - r;
float2 p0 = R * float2(sin(start), -cos(start));
float2 p1 = R * float2(sin(start + len), -cos(start + len));
return min(length(q - p0), length(q - p1)) - r;
}
float SegmentSdf(float2 q, float2 a, float2 b) {
float2 pa = q - a, ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h);
}
float Track(float2 p) {
float2 q = p - size * 0.5;
float r = thickness * 0.5;
if (kind < 0.5) return ArcSdf(q, Radius(), r, startAngle, sweep);
return SegmentSdf(q, BarStart(), BarEnd()) - r;
}Radius(), BarStart() and BarEnd() are tiny helpers: the ring is centered in the box, and a bar goes left to right when it’s wide or bottom to top when it’s tall. The groove itself takes no new ideas at all — it’s the pressed state from the first part, the same two soft copies of the shape, inverted:
half4 main(float2 p) {
float d = Track(p);
float inside = 1.0 - smoothstep(-0.75, 0.75, d);
// a groove is just a pressed shape: the same two soft copies, inverted
float innerDark = 1.0 - Soft(Track(p + lightDir * shadowShift), shadowBlur);
float innerLite = 1.0 - Soft(Track(p - lightDir * shadowShift), shadowBlur);
half3 col = baseColor.rgb;
col = mix(col, darkColor.rgb, innerDark * 0.75);
col = mix(col, lightColor.rgb, innerLite * 0.8);
return half4(col, 1.0) * inside;
}On the screen, the four widgets are just four calls:
// the timer: a full ring around a concave dish with the time in it
Box(Modifier.size(224.dp).accentTrack(TrackShape.Ring, thickness = 16.dp)) { /* ... */ }
// the volume knob: 270° of a ring, starting at the bottom left
Box(
Modifier
.size(140.dp)
.accentTrack(TrackShape.Ring, thickness = 12.dp, startDeg = 225f, sweepDeg = 270f)
) { /* ... */ }
// the dimmer and the download: capsules, tall and wide
Box(Modifier.size(width = 44.dp, height = 140.dp).accentTrack(TrackShape.Bar, thickness = 44.dp))
Box(Modifier.fillMaxWidth().height(18.dp).accentTrack(TrackShape.Bar, thickness = 18.dp))Paint first, on purpose
The fill is the same shape as the groove, only shorter and thinner. Shorter is obvious: for a ring we cut it at sweep * progress, for a bar we move its end. Thinner matters more than it seems. With a radius of 0.3 · thickness against the groove’s 0.5, there is a gap between the fill and the walls, and later this gap is where the glow will live.
// the fill is the same shape, shorter and a bit thinner: it lies at the bottom of the groove
float Fill(float2 p) {
float2 q = p - size * 0.5;
float r = thickness * 0.3;
if (kind < 0.5) return ArcSdf(q, Radius(), r, startAngle, sweep * progress);
float2 a = BarStart();
return SegmentSdf(q, a, mix(a, BarEnd(), progress)) - r;
}
// in main(), after the groove:
// the accent: paint it on top, just to see the shape
float fill = 1.0 - smoothstep(-0.75, 0.75, Fill(p));
col = mix(col, accentA.rgb, fill);For now I paint the fill with one flat color, just to see the shapes. And here is the cheap look I promised — the left image below. Four stickers on a soft surface. Nothing is wrong with the geometry, it’s the material that is wrong: it’s paint.
Get Alex Volkov’s stories in your inbox
Join Medium for free to get updates from this writer.
The first thing that helps is a gradient, but not just any gradient. A gradient in x or y would color the left half of the ring purple and the right half pink — that says nothing about progress. The color should change along the track, from its start to its end. So we need one more function: where along the track the current pixel is, from 0 to 1.
// where along the track a point is, 0..1: the color depends on it, not on x or y
float Along(float2 p) {
float2 q = p - size * 0.5;
if (kind < 0.5) {
float len = sweep * progress;
float u = mod(Angle(q) - startAngle + TAU, TAU);
if (u > len) {
// in the round caps: take the closest end of the fill
float2 p0 = Radius() * float2(sin(startAngle), -cos(startAngle));
float2 p1 = Radius() * float2(sin(startAngle + len), -cos(startAngle + len));
u = length(q - p0) < length(q - p1) ? 0.0 : len;
}
return u / sweep;
}
float2 a = BarStart(), b = BarEnd();
return clamp(dot(q - a, b - a) / dot(b - a, b - a), 0.0, 1.0);
}
// in main():
half3 accent = mix(accentA.rgb, accentB.rgb, Along(p));For a ring it’s the same angle we used for the cut, divided by the sweep. For a bar it’s a projection on the segment. The only special case is the round cap at the end of the fill — its pixels are outside the angular range, so they get the value of the closest end. Then each widget gets a pair of colors:
// one accent per widget: where the track starts and where it ends
private val Focus = Color(0xFFFF2D7A) to Color(0xFFA25BFF)
private val Sound = Color(0xFF2FE6FF) to Color(0xFF3D6BFF)
private val Warm = Color(0xFFFF6A2A) to Color(0xFFFFC94A)
private val Sync = Color(0xFF21C7C7) to Color(0xFF52F08A)From paint to light
The gradient makes the widgets readable, but they still look flat. The fill needs to become a tube, and a tube needs a normal. We don’t have any 3D geometry, but we have something just as good: the distance to the fill. Inside the fill, -Fill(p) goes from 0 on the edge to r on the center line. Divide it by r, and you have a coordinate h across the tube. And the height of a half-circle over that coordinate is √(h · (2 − h)).
The direction of the normal in the screen plane is the gradient of the same distance function — it always points away from the center line, whatever the shape. I take it with central differences, four extra calls of Fill, which is fine for something this small.
// the fill is a tube of light: its own normal comes from the distance to its center line
float r = thickness * 0.3;
float h = clamp(-f / r, 0.0, 1.0); // 0 on the edge, 1 in the middle
float3 L = normalize(float3(lightDir * 0.6, 0.8));
float2 g = normalize(float2(Fill(p + float2(1.0, 0.0)) - Fill(p - float2(1.0, 0.0)),
Fill(p + float2(0.0, 1.0)) - Fill(p - float2(0.0, 1.0))) + 1e-4);
float3 n = normalize(float3(g * (1.0 - h), sqrt(max(h * (2.0 - h), 0.0))));
float sheen = pow(max(dot(n, L), 0.0), 24.0);
half3 tube = accent * (0.75 + 0.35 * n.z) + sheen * 0.45;
col = mix(col, tube, fill);Here is how those normals look if you output them as colors — this is a temporary debug line in the shader, not the final look:
With the normal, the rest is ordinary lighting. The base of the tube is a bit brighter on top (0.75 + 0.35 · n.z), and a narrow highlight (pow(…, 24)) appears where the tube faces the same light as the rest of the screen. Then the last rule: the light leaks out. Outside the fill, its distance function is positive, and an exponential falloff of it gives a soft halo that fills the gap between the tube and the walls:
// and the light leaks out of it: the groove around the fill picks up its color
float glow = exp(-max(f, 0.0) / (thickness * 0.35)) * (1.0 - fill);
col += accent * glow * 0.32;This is the moment the screen started to look like the shots I was collecting. There is no blur anywhere, and nothing here is expensive — just a handful of distance calls per pixel, and only inside the tracks.
Bringing it to life
A progress bar that doesn’t move is a picture. Before making things move, one small change in the API: progress becomes a lambda, and the modifier calls it inside drawBehind.
fun Modifier.accentTrack(
shape: TrackShape,
progress: () -> Float, // how much of the track is filled, 0..1; read in the draw phase
colors: Pair<Color, Color>, // accent at the start and at the end of the track
// ... thickness, startDeg, sweepDeg
): Modifier = composed {
// ...
val time = rememberAccentTime()
drawBehind {
// ...
shader.setFloatUniform("progress", progress().coerceIn(0f, 1f))
shader.setFloatUniform("time", time.value)Why a lambda? The timer changes its progress every frame. If it were a plain Float, the whole widget would recompose 60 times per second. Read inside the draw phase, a new value only redraws the track. The timer text is a separate story: it needs a new value once a second, so it goes through derivedStateOf:
private fun FocusTimer() {
// a 25-minute focus session that started 6 minutes ago; one real second per second
val left = remember { Animatable(19f * 60f) }
LaunchedEffect(Unit) {
left.animateTo(
0f, tween(durationMillis = (left.value * 1000).toInt(), easing = LinearEasing)
)
}
// the text needs a new value once a second, the ring reads left.value on every frame itself
val seconds by remember { derivedStateOf { left.value.toInt() } }
Box(
modifier = Modifier
.size(224.dp)
.accentTrack(TrackShape.Ring, { left.value / (25f * 60f) }, Focus, thickness = 16.dp),
contentAlignment = Alignment.Center
) {
// ... the concave dish with "%02d:%02d".format(seconds / 60, seconds % 60)
}
}The volume knob and the lamp dimmer are dragged with a finger. The track itself doesn’t care where the value comes from — it’s just another lambda:
var volume by remember { mutableFloatStateOf(0.62f) }
// ...
.accentTrack(
TrackShape.Ring, { volume }, Sound,
thickness = 12.dp, startDeg = 225f, sweepDeg = 270f
)
.pointerInput(Unit) {
detectVerticalDragGestures { _, dy ->
volume = (volume - dy / 900f).coerceIn(0f, 1f)
}
}And two small touches on the tube itself. A soft pulse runs from the start of the fill to its head every three seconds, and the head of the fill is the hottest point, slightly whiter than the rest. Both are Gaussian bumps of the same Along() coordinate. Both are multiplied by n.z, so they stay on top of the tube and don’t smear onto its edges.
// a soft pulse runs from the start of the fill to its head
float t = Along(p);
float pulsePos = fract(time * 0.35) * progress * 1.3; // runs a bit past the head, then again
float pulse = exp(-pow((t - pulsePos) * 9.0 / max(progress, 0.05), 2.0));
tube += accent * pulse * 0.35 * n.z;
// the head of the fill is the hottest point
float head = exp(-pow((t - progress) * 14.0, 2.0));
tube += half3(1.0) * head * 0.25 * n.z;The trap in the light theme
The screen has a theme toggle in the corner — the same NeuSurface(dark = …) as last time, it just swaps the palette. I built everything above in the dark theme, and it looked great. Then I switched to the light one.
On the left is exactly the shader from above. The halo around the fill is white, not colored, and the knob looks like it’s overexposed. The reason is the +=. Adding light works when there’s room for it: a dark gray plus a bit of pink is a darker pink. But the light material is already close to white, so adding anything to it just clips to white, whatever the color.
The fix is one line. Instead of adding the accent, mix towards it:
// and the light leaks out of it: the groove around the fill picks up its color.
// mix, not add: on a light material adding light just burns it to white
float glow = exp(-max(f, 0.0) / (thickness * 0.35)) * (1.0 - fill);
col = mix(col, accent, glow * 0.4);In the dark theme, this looks almost the same as before, because mixing towards a bright color from a dark one is basically adding. In the light theme, the halo finally keeps its hue. I like this bug, because it shows something easy to forget: “glow” isn’t a formula, it’s light meeting a surface, and the surface decides what the light looks like.
Wrapping up
Looking back, the accent didn’t need any new kind of math. It’s the same signed distances as the soft shadows, used three more times: for the groove, for the fill, and for the tube’s normal. What changed is the decision about what the color is. Once it’s light and not paint, every next step is obvious: light needs a place to sit, a shape to have a highlight, and a surface to leak onto.
All four widgets live in the article/neumorphism-accents branch, one commit per step, so you can check out any stage and see exactly the screenshots from this article. If you build something with these tracks, I’d really like to see it — drop it in my Telegram channel, that’s also where new effects show up first. Or find me on Twitter (X). And if this was useful, sharing it is the best way to support the project. Thanks for reading!










