Neumorphism, Explained with Light
Android Jetpack Compose with runtimeShaders
Hi everyone!
This tutorial is a bit different from my usual ones. Normally I take one effect and build it step by step. This time I want to take a whole design style — neumorphism — and show that behind it there is one simple idea about light. Once you see this idea, the whole style fits into one small shader, and every element on the screen, a button, a card or a toggle, is just that shader with different parameters.
I have to admit, I have mixed feelings about neumorphism. Done badly, it looks heavy and dirty: thick dark shadows, sharp bevels, everything glued to everything. Done well, the whole screen looks like it’s made of one soft matte material, calm and very pleasant to touch. And the difference between the two is not a matter of taste. It’s physics — or at least a very simplified version of it :)
As usual, I’ve split everything into a few parts:
- Theory: where the style came from, what its main idea is, and why some versions look airy while others look heavy. There is no code in this part, but please don’t skip it — everything later is built on it.
- The shader: one
Modifier.neumorphicthat draws soft shadows, a concave surface and a pressed state from a single SDF and a single light. - A real screen: a small thermostat made only of these elements — a dial, two buttons, cards and toggles — plus a light that follows your finger and a dark theme.
In the end, we’ll get something like this:
If you are really new to shaders and have no clue what is going on here, feel free to read my book for very beginners.
A bit of theory
From skeuomorphism to flat, and halfway back
Back in the days of iOS 6, interfaces imitated real things: leather, wood, glass, stitched paper. That was skeuomorphism. Then flat design came and threw all of it away — no textures, no shadows, just colored shapes. Material Design brought shadows back, but those are the shadows of paper cards lying on top of each other.
At the end of 2019, a few Dribbble shots went viral, and a new style got its name: neumorphism, from “new skeuomorphism”. It tries to stay somewhere in between. There are no textures and no fake materials. There is only one material — the background itself — and light.
One material
This is the core idea, and everything else follows from it. A neumorphic element is not placed on top of the background. It is extruded from it, like a shape pressed out of a sheet of soft plastic. That’s why the element has exactly the same color as the background. Not a similar one — exactly the same.
Think about what it means: if you turn the light off, the element disappears. There is no outline and no fill, nothing at all. Only light tells you that there is a shape. In the first step of the shader, I’ll show it quite literally.
One light, two shadows
Since the element is just a bump on the same surface, it is lit by the same light as everything else. Designers almost always put the light in the top-left corner, and so do I. Then:
- the side facing the light catches more of it than the flat background, so it gets lighter;
- the side facing away gets less light, so it gets darker;
- the flat top gets exactly the same light as the background, so it keeps the background color.
In 2D we don’t render the bump itself. Instead, the style uses two shadows: a dark one, moved away from the light, and a light one, moved towards it. To be honest, the light “shadow” isn’t very physical — it’s the lit edge of the bump, blurred out. But it’s exactly what makes the element look extruded rather than lying on top. Without it, you just get a regular card with a drop shadow.
A pressed element is the same thing turned inside out: a dent instead of a bump. Now the wall near the light is in shadow and the far wall is lit, so both shadows move inside the shape.
Airy or heavy
Both of these are drawn with the modifier we are going to write. On the left, the shadows are moved by 9dp and blurred by 20dp. On the right, they are moved by 14dp and blurred by only 4dp. That’s the only difference, and the right one already looks like a sticker with a cheap drop shadow.
Here are the rules I follow to keep it airy:
- The blur is two or three times the distance. A short, sharp shadow means “a small, hard light and an object high above the surface” — the opposite of a soft matte material.
- Shadows are low-contrast. The dark color is just a darker, slightly bluer version of the base color (
#A3B1C6for#E0E5EC), never black. - The background is never pure white, otherwise there is no room left for the light shadow.
- No hard bevels and no strong gradients. Gradients do exist, but they are very subtle — we’ll get to concave surfaces later.
And one more rule, about accessibility. Low contrast is the price of this style. Keep the text contrast high, don’t build a whole app out of soft buttons, and show state with one accent color, like the tiny LED on a real wall switch.
Why a shader?
On the web, neumorphism is usually two box-shadows. On Android, people draw blurred shapes behind the element with BlurMaskFilter. It works, but every element turns into a few blur passes, inner shadows need clipping tricks, and the light direction is baked into hand-tuned offsets.
With a shader, all of it comes from one distance function: the outer shadows, the inner shadows and the surface itself. There are no blur passes at all. And the light is just a uniform — move it, and every element on the screen reacts consistently, because they all use the same formula. That’s the main reason I wanted to build it this way.
Compose setup
Let’s start with a minimal setup: a background and two elements, a rounded square and a circle. More samples will appear on this screen as soon as the shader can actually draw them. All the colors live in one object — Base is the material, Dark and Light are the two shadows:
object NeuColors {
val Base = Color(0xFFE0E5EC) // the one material: background and every element
val Dark = Color(0xFFA3B1C6) // shadow, away from the light
val Light = Color(0xFFFFFFFF) // highlight, towards the light
val Text = Color(0xFF4A5368)
val Hint = Color(0xFF9AA4B5)
}@Composable
fun NeumorphismBasicsScreen() {
NeuSurface {
Row(
modifier = Modifier.align(Alignment.Center),
horizontalArrangement = Arrangement.spacedBy(64.dp)
) {
NeuSample("SQUARE") {
Box(Modifier.size(110.dp).neumorphic(cornerRadius = 22.dp))
}
NeuSample("CIRCLE") {
Box(Modifier.size(110.dp).neumorphic(cornerRadius = 55.dp))
}
}
}
}NeuSurface is just a Box that fills the screen with NeuColors.Base, and NeuSample is a Column with a caption under the element, so I’ll skip both.
Now the modifier. This time we don’t use RenderEffect at all. A render effect post-processes what is already drawn inside a layer, but we want to draw something new — and partly outside the element, because shadows go beyond its bounds. So we draw with a ShaderBrush in drawBehind:
fun Modifier.neumorphic(
cornerRadius: Dp,
): Modifier = composed {
val shader = remember { RuntimeShader(neumorphicShader) }
val brush = remember { ShaderBrush(shader) }
drawBehind {
shader.setFloatUniform("size", size.width, size.height)
shader.setFloatUniform("radius", min(cornerRadius.toPx(), size.minDimension / 2f))
drawRect(brush)
}
}
@Language("AGSL")
private val neumorphicShader = """
uniform float2 size;
uniform float radius;
float sdRoundBox(float2 p, float2 halfSize, float r) {
float2 q = abs(p) - halfSize + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
float Shape(float2 p) {
return sdRoundBox(p - size * 0.5, size * 0.5, radius);
}
half4 main(float2 p) {
float inside = 1.0 - smoothstep(-0.75, 0.75, Shape(p));
return half4(1.0, 0.3, 0.3, 1.0) * inside; // debug: paint the shape red for a moment
}
""".trimIndent()Shape(p) returns the signed distance to the edge of the element: negative inside, zero exactly on the edge, positive outside. sdRoundBox is the classic rounded box from Inigo Quilez’s collection of 2D distance functions. It only needs the half-size and the corner radius — and yes, a circle is just a rounded box whose radius is half of its size.
inside is an antialiased mask: smoothstep(-0.75, 0.75, d) gives a soft edge about one and a half pixels wide.
If everything is set up correctly, you should see red shapes where the elements are. And now the experiment I promised: let’s paint them with the background color instead of red.
The shader
Soft shadows without blur
Here is the trick that makes everything cheap. A blurred shape is, roughly, “how much of the shape is around this pixel”. With an SDF we don’t need to look around: the distance already tells us how far the edge is. So a soft copy of the shape is just one line:
// a blurred copy of the shape straight from its sdf - no blur pass needed
float Soft(float d, float blur) {
return 1.0 - smoothstep(-blur, blur, d);
}At d = -blur we are deep inside, and the value is 1. At d = +blur we are far outside, and it’s 0. In between, there is a smooth falloff, 2 · blur wide.
It isn’t a real Gaussian blur, and the corners come out a bit rounder than with a real one, but for soft shadows nobody will ever see the difference.
Two copies
Now we need two such copies: one moved away from the light (the dark shadow) and one moved towards it (the light shadow). To move a shape in a shader, we move the coordinates in the opposite direction: Shape(p + offset) draws the shape shifted by -offset.
First, the Compose side. The light is a unit vector that points towards it. It comes from a CompositionLocal, so every element on the screen shares the same light:
// one light for the whole screen: a unit vector pointing towards it (screen space, y goes down)
val LocalLightDirection = compositionLocalOf { Offset(-0.7071f, -0.7071f) }
fun Modifier.neumorphic(
cornerRadius: Dp,
distance: Dp = 8.dp,
blur: Dp = 16.dp,
): Modifier = composed {
val light = LocalLightDirection.current
val shader = remember { RuntimeShader(neumorphicShader) }
val brush = remember { ShaderBrush(shader) }
drawBehind {
// shadows live outside of the element: they can't go further than shift + blur
val margin = (distance + blur).toPx() + 2f
shader.setFloatUniform("size", size.width, size.height)
shader.setFloatUniform("radius", min(cornerRadius.toPx(), size.minDimension / 2f))
shader.setFloatUniform("lightDir", light.x, light.y)
shader.setFloatUniform("shadowShift", distance.toPx())
shader.setFloatUniform("shadowBlur", blur.toPx())
shader.setColorUniform("baseColor", NeuColors.Base.toArgb())
shader.setColorUniform("darkColor", NeuColors.Dark.toArgb())
shader.setColorUniform("lightColor", NeuColors.Light.toArgb())
drawRect(
brush = brush,
topLeft = Offset(-margin, -margin),
size = Size(size.width + margin * 2, size.height + margin * 2)
)
}
}Note the margin. The shadows lie outside the element, so we draw a rectangle that is bigger than the element by distance + blur. drawBehind doesn’t clip, so this just works. The colors go through setColorUniform, and on the shader side they are declared with layout(color), which lets Android convert them into the color space it renders in.
Get Alex Volkov’s stories in your inbox
Join Medium for free to get updates from this writer.
And the shader:
uniform float2 lightDir; // unit vector pointing towards the light
uniform float shadowShift; // how far the shadows are moved, px
uniform float shadowBlur; // how soft they are, px
layout(color) uniform half4 baseColor;
layout(color) uniform half4 darkColor;
layout(color) uniform half4 lightColor;
// ... sdRoundBox, Shape and Soft from above
half4 main(float2 p) {
float inside = 1.0 - smoothstep(-0.75, 0.75, Shape(p));
// the same shape, moved away from the light and towards it
float dark = Soft(Shape(p + lightDir * shadowShift), shadowBlur) * 0.62;
float lite = Soft(Shape(p - lightDir * shadowShift), shadowBlur) * 0.8;
// the highlight over the shadow, premultiplied
half4 shadows = half4(lightColor.rgb * lite + darkColor.rgb * dark * (1.0 - lite),
lite + dark * (1.0 - lite));
half4 surface = baseColor;
return mix(shadows, surface, inside);
}The dark copy is moved away from the light, the light copy towards it. Then we compose them: the light shadow goes over the dark one (both premultiplied, as AGSL expects), and the element itself goes on top, using the inside mask. 0.62 and 0.8 are just opacities: the light shadow is stronger, because white on light gray has little contrast anyway.
A concave surface
Flat elements already work, but they look a bit like cut-outs. A real soft UI often has a barely visible gradient on the surface, and here we finally need real lighting. In the global illumination tutorial I faked the normals by pointing them away from the center of the element. This time, we’ll get them from an actual surface.
Imagine that the element is a very shallow bowl. Its height grows with the squared distance from the center: h = k · |q|², where q goes from -1 to 1 across the element. The normal of this surface tilts towards the center, and the closer to the edge, the stronger: n = normalize(−2k · q, 1). I pass curve = −k, so negative values mean concave.
// the surface: Lambert on a very shallow dish
float2 q = (p - size * 0.5) / (size * 0.5); // -1..1 across the element
float3 n = normalize(float3(2.0 * curve * q, 1.0)); // normal of the dish
float3 L = normalize(float3(lightDir * 0.6, 0.8)); // the same light, a bit above the screen
half4 surface = half4(baseColor.rgb * (1.0 + 0.9 * (dot(n, L) - L.z)), 1.0);Then it’s Lambert, with one small twist. Instead of using dot(n, L) as the brightness directly, I use how much it differs from a flat surface. L is the same light direction, lifted above the screen ( z = 0.8), because a light lying exactly in the screen plane would only light the walls.
The important part is - L.z. For a flat surface n = (0, 0, 1), so dot(n, L) is exactly L.z, and the color stays exactly baseColor. Flat parts must keep the background color — that’s the whole philosophy from the theory part. Only the slopes get a bit lighter or darker.
On the Compose side, it’s one more parameter: curve: Float = 0f, where 0 is a flat plate and negative values make a dish. The concave circle on the screen is neumorphic(cornerRadius = 55.dp, curve = -0.075f). I keep one flat square next to it for comparison and add a concave square below.
Why concave? Inside a bowl, the wall near the light is turned away from it and gets slightly darker, while the far wall faces the light and gets lighter. That’s the opposite of the outer shadows, so the two gradients balance each other, and the element looks delicate. Flip the sign to make it convex, and both gradients go the same way: the contrast doubles, and the element suddenly looks heavy and bulgy. It’s one character, try it :)
Pressed
A pressed element is a dent instead of a bump, so the shadows go inside. And again, no new tools — the same soft copies, just inverted. 1 − Soft(...) is everything outside a shifted copy, and since the surface is only visible inside the element, we see just a thin strip of it along the edge. The copy moved away from the light leaves a strip near the light — that’s the inner shadow. The copy moved towards the light leaves a strip on the far side — that’s the inner highlight.
float dark = Soft(Shape(p + lightDir * shadowShift), shadowBlur) * 0.62 * (1.0 - press);
float lite = Soft(Shape(p - lightDir * shadowShift), shadowBlur) * 0.8 * (1.0 - press);
// ... shadows and the concave surface, exactly as before
// pressed: the same two shadows, but inside the shape and with the opposite sign
float innerDark = 1.0 - Soft(Shape(p + lightDir * shadowShift * 0.5), shadowBlur * 0.5);
float innerLite = 1.0 - Soft(Shape(p - lightDir * shadowShift * 0.5), shadowBlur * 0.5);
surface.rgb = mix(surface.rgb, darkColor.rgb, innerDark * 0.6 * press);
surface.rgb = mix(surface.rgb, lightColor.rgb, innerLite * 0.8 * press);press goes from 0 to 1: it fades the outer shadows out and brings the inner ones in. Inside, I use half the distance and half the blur — a dent is shallower than a bump, and with the full values the shadow eats the whole element. On the Compose side, press comes from the interaction source, and the screen finally gets its fourth sample — a square that is pressed by default:
// a shape you can press: the press state goes straight into the shader
@Composable
private fun NeuShape(cornerRadius: Dp, curve: Float = 0f, pressedByDefault: Boolean = false) {
val interaction = remember { MutableInteractionSource() }
val isPressed by interaction.collectIsPressedAsState()
val press by animateFloatAsState(
targetValue = if (isPressed != pressedByDefault) 1f else 0f,
animationSpec = tween(durationMillis = 140),
label = "press"
)
Box(
Modifier
.size(110.dp)
.neumorphic(cornerRadius, curve = curve, press = press)
.clickable(interaction, indication = null) {}
)
}A real screen
Now let’s build something real out of these pieces: a thermostat with a big concave dial, two round buttons and two cards with toggles. Every element on this screen, including the toggle track and its knob, is the same Modifier.neumorphic with different parameters.
The button is the pressable shape from above with some content inside. The content also gets the pressed state, so the icon goes a bit darker while the button is in the surface:
// A round concave button that sinks into the surface while it's pressed
@Composable
fun NeuButton(onClick: () -> Unit, /* ... */ content: @Composable (pressed: Boolean) -> Unit) {
// ... interaction, isPressed and press, exactly like in NeuShape
Box(
modifier = modifier
.size(size)
.neumorphic(cornerRadius = size / 2, curve = -0.075f, press = press)
.clickable(interaction, indication = null, onClick = onClick),
contentAlignment = Alignment.Center
) {
content(isPressed)
}
}The toggle is the most interesting one. Its track is pressed into the card ( press = 1f), and the knob is a small concave dish lying in it. The only accent color on the whole screen is a tiny green LED, like on a real wall switch. That’s my answer to the accessibility problem: the state must be readable even if you can’t see subtle shadows.
// The track is pressed into the surface, the knob is a small concave dish on top of it
@Composable
fun NeuToggle(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
val t by animateFloatAsState(
targetValue = if (checked) 1f else 0f,
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "toggle"
)
Box(
modifier = Modifier
.size(width = 64.dp, height = 34.dp)
.neumorphic(cornerRadius = 17.dp, press = 1f, distance = 4.dp, blur = 8.dp)
.clickable(remember { MutableInteractionSource() }, indication = null) {
onCheckedChange(!checked)
}
.padding(4.dp)
) {
// a tiny status LED, like on a real wall switch: the only accent on the screen
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 9.dp)
.size(8.dp)
.background(lerp(NeuColors.Base, NeuColors.Accent, t), CircleShape)
)
Box(
modifier = Modifier
.offset(x = 30.dp * t)
.size(26.dp)
.neumorphic(cornerRadius = 13.dp, curve = -0.075f, distance = 3.dp, blur = 6.dp)
)
}
}And the screen itself is just a layout ( target, nightMode and ecoMode are plain remember states):
NeuSurface {
Column(/* ... padding, centered; spacers are skipped below */) {
// ... the "Living room" header
// the dial: one big concave dish
Box(
modifier = Modifier
.size(220.dp)
.neumorphic(cornerRadius = 110.dp, curve = -0.075f, distance = 14.dp, blur = 28.dp),
contentAlignment = Alignment.Center
) {
// ... "22°" and "target"
}
Row(horizontalArrangement = Arrangement.spacedBy(56.dp)) {
NeuButton(onClick = { target-- }) { pressed ->
Text("\u2212", fontSize = 32.sp, color = iconColor(pressed))
}
NeuButton(onClick = { target++ }) { pressed ->
Text("+", fontSize = 32.sp, color = iconColor(pressed))
}
}
// flat cards with toggles
NeuSettingCard("Night mode", "Dim the lights after 22:00", nightMode) { nightMode = it }
NeuSettingCard("Eco mode", "Keep 19° while nobody is home", ecoMode) { ecoMode = it }
}
}NeuSettingCard is a Row with two texts and a NeuToggle, drawn with the same modifier: flat, cornerRadius = 24.dp.
Small details
One light for everything
The nicest part of building it with a shader: the light is just a uniform. Let’s make it follow the finger. First, LocalLightDirection now holds a State<Offset> instead of a plain Offset, and the modifier reads it inside drawBehind:
// one light for the whole screen: a unit vector pointing towards it (screen space, y goes down).
// It's a State, so a moving light only redraws the elements, without recomposition.
val DefaultLight = Offset(-0.7071f, -0.7071f)
val LocalLightDirection = staticCompositionLocalOf<State<Offset>> { mutableStateOf(DefaultLight) }
// inside Modifier.neumorphic:
val light = LocalLightDirection.current
// ...
drawBehind {
val l = light.value // <-- read here, inside the draw phase
// ...
shader.setFloatUniform("lightDir", l.x, l.y)
// ... the rest stays the same
}This way, when the light moves, Compose only redraws the elements — there is no recomposition on every frame. Second, NeuSurface listens to the finger:
@Composable
fun NeuSurface(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
LightStatusBar()
// the light follows the finger and springs back when it's released
var target by remember { mutableStateOf(DefaultLight) }
val light = animateOffsetAsState(
targetValue = target,
animationSpec = spring(dampingRatio = 0.8f, stiffness = Spring.StiffnessLow),
label = "light"
)
CompositionLocalProvider(LocalLightDirection provides light) {
Box(
modifier = modifier
.fillMaxSize()
.background(NeuColors.Base)
.pointerInput(Unit) {
awaitEachGesture {
// Initial pass: we only watch the finger, buttons still get their clicks
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
do {
val event = awaitPointerEvent(PointerEventPass.Initial)
val finger = event.changes.first().position
val fromCenter = finger - size.center.toOffset()
if (fromCenter.getDistance() > 1f) {
target = fromCenter / fromCenter.getDistance()
}
} while (event.changes.any { it.pressed })
target = DefaultLight
}
},
content = content
)
}
}PointerEventPass.Initial lets the parent see the events before its children, and since we never consume them, buttons and toggles still get their clicks. The light direction is the normalized vector from the center of the screen to the finger, and when the finger goes up, a spring brings the light back to the top-left. LightStatusBar() only makes the status bar icons dark, since the material is light.
Look at the dial on the last image: the light comes from below, and the screen still makes sense, because every element follows the same light. That’s really hard to get with hand-tuned shadows.
Dark neumorphism
You might have noticed the Night mode toggle on the screen. Until now it switched nothing :) Let’s make it switch the theme, because neumorphism in the dark looks really nice — but it has its own rule.
In the dark, the highlight can’t be white. A white edge on a dark surface doesn’t read as light falling on a material, it reads as a glowing outline. So the light shadow becomes just a slightly lighter version of the base color, and the dark shadow goes almost black. Everything else stays the same: one material, one light, two soft copies. The contrast gets even lower than in the light theme, so the green LED matters even more here.
And the nice part: the shader doesn’t change at all, it only gets different colors. So the five colors go into a palette, and a theme is just another palette:
// the same five colors for each theme; the shader doesn't care which one it gets
@Immutable
data class NeuPalette(
val base: Color,
val dark: Color,
val light: Color,
val text: Color,
val hint: Color,
)
val LightPalette = NeuPalette(
NeuColors.Base, NeuColors.Dark, NeuColors.Light, NeuColors.Text, NeuColors.Hint
)
// in the dark, the highlight can't be white: it's just a slightly lighter base
val DarkPalette = NeuPalette(
base = Color(0xFF2B2F36),
dark = Color(0xFF16181C),
light = Color(0xFF3C414B),
text = Color(0xFFD5DAE3),
hint = Color(0xFF7D8696),
)
// ... fun lerp(a: NeuPalette, b: NeuPalette, t: Float): all five colors with Compose's lerp
// a State again, so switching the theme only redraws the elements
val LocalNeuPalette = staticCompositionLocalOf<State<NeuPalette>> {
mutableStateOf(LightPalette)
}NeuSurface gets a dark flag and animates between the two palettes. Exactly like the light direction, the palette is a State, and the modifier reads it inside drawBehind, so a 700 ms theme switch doesn’t recompose a single element. The thermostat just passes NeuSurface(dark = nightMode), and the texts take their colors from the same palette.
fun NeuSurface(
modifier: Modifier = Modifier,
dark: Boolean = false,
content: @Composable BoxScope.() -> Unit,
) {
StatusBarIcons(darkIcons = !dark)
// the theme is just a different set of colors: animate between the two palettes
val theme by animateFloatAsState(
targetValue = if (dark) 1f else 0f,
animationSpec = tween(durationMillis = 700),
label = "theme"
)
val palette = remember { derivedStateOf { lerp(LightPalette, DarkPalette, theme) } }
// ... the light, exactly as before
CompositionLocalProvider(
LocalLightDirection provides light,
LocalNeuPalette provides palette,
) {
Box(
modifier = modifier
.fillMaxSize()
.drawBehind { drawRect(palette.value.base) }
// ... pointerInput for the light
// and in Modifier.neumorphic, next to the light:
val palette = LocalNeuPalette.current
drawBehind {
val l = light.value
val c = palette.value
// ...
shader.setColorUniform("baseColor", c.base.toArgb())
shader.setColorUniform("darkColor", c.dark.toArgb())
shader.setColorUniform("lightColor", c.light.toArgb())Look at the dial in the dark: it’s the same Lambert, just multiplied by a darker base color, so the gradient got even more subtle. And the light still follows the finger — the theme and the light are just two independent inputs of the same formula.
The whole shader
And here is the whole main() of the final shader:
half4 main(float2 p) {
float inside = 1.0 - smoothstep(-0.75, 0.75, Shape(p));
// the same shape, moved away from the light and towards it
float dark = Soft(Shape(p + lightDir * shadowShift), shadowBlur) * 0.62 * (1.0 - press);
float lite = Soft(Shape(p - lightDir * shadowShift), shadowBlur) * 0.8 * (1.0 - press);
// the highlight over the shadow, premultiplied
half4 shadows = half4(lightColor.rgb * lite + darkColor.rgb * dark * (1.0 - lite),
lite + dark * (1.0 - lite));
// the surface: Lambert on a very shallow dish
float2 q = (p - size * 0.5) / (size * 0.5); // -1..1 across the element
float3 n = normalize(float3(2.0 * curve * q, 1.0)); // normal of the dish
float3 L = normalize(float3(lightDir * 0.6, 0.8)); // the same light, a bit above the screen
half4 surface = half4(baseColor.rgb * (1.0 + 0.9 * (dot(n, L) - L.z)), 1.0);
// pressed: the same two shadows, but inside the shape and with the opposite sign
float innerDark = 1.0 - Soft(Shape(p + lightDir * shadowShift * 0.5), shadowBlur * 0.5);
float innerLite = 1.0 - Soft(Shape(p - lightDir * shadowShift * 0.5), shadowBlur * 0.5);
surface.rgb = mix(surface.rgb, darkColor.rgb, innerDark * 0.6 * press);
surface.rgb = mix(surface.rgb, lightColor.rgb, innerLite * 0.8 * press);
return mix(shadows, surface, inside);
}The full modifier with all the uniforms is in the article/neumorphism-light branch on my GitHub, and every step of this tutorial is a separate commit there.
The mental model is simple: the element is made of the same material as the background, and only the light reveals it. One SDF gives you everything: two shifted soft copies are the outer shadows, the same copies inverted are the inner ones, and a shallow concave normal with Lambert gives the surface its gradient. Just keep the flat parts exactly the background color.
Tweaking is the fun part: try a different base color (soft UI looks great in muted pastels), a bigger blur, or a light that follows the tilt of the phone instead of the finger.
Thanks for reading and happy coding!










