Rounded UI button
A pill-shaped button with an inner border, a chevron icon and a soft drop shadow, all cut from signed distances.
- 01GoalShape
- 02Technique2D SDF composition
- 03ExecutionRounded UI button
Parameters
Copy for your platform
- GLSLCopy
- GodotCopy
Node chain
Built step by step, in GLSL style, with its node names.
Built step by step, in Godot style, with its node names.
No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.
No Unity version of this card yet, so the nodes show the GLSL names.
No Blender version of this card yet, so the nodes show the GLSL names.
lengthExpressionlengthlengthlength UV01 Rounded box distance
maxMaxmaxmaxmax In02 Subtract: inner border
minMinminminmin In03 Union: chevron
smoothstepSmoothStepsmoothstepsmoothstepsmoothstep In04 Soft shadow
mixMixmixmixmix In05 Layer the colors
fwidthDerivativeFuncfwidthfwidthfwidth In06 Anti-aliased edge
How it works
A button is a small stack of shapes: a body, a border, an icon and a shadow. Drawing each one as an image means four
images that blur at another size. Drawing them as signed distancesA function that returns how far a point is from the edge of a shape, negative inside and positive outside. Zero is the outline itself. Signed distance field (SDF) A function that returns how far a point is from the edge of a shape, negative inside and positive outside. Zero is the outline itself.circle(p) = |p| − r
The body: a rounded box. The coordinates are centered and corrected for the aspect ratio, as in the Shape cards
before this one. sdRoundBox first measures the distance to a box shrunk by the corner radius, then subtracts the
radius from the result. Subtracting a constant grows a distance field outward by that much, and growing a sharp corner
by r turns it into a quarter circle of radius r, so the box returns to its full size with rounded corners. The
radius is clamped to the smaller half size, which turns a short, wide button into a pill instead of breaking the shape.
The border: a subtraction. body + borderWidth is the same box shrunk inward by the border width. Subtracting one
shape from another is max(a, -b), so max(body, -(body + borderWidth)) keeps the part of the body that is not inside
the smaller copy: a stroke that follows the inside of the edge, with the same rounded corners.
The icon: a union. Each arm of the chevron is the distance to a line segment, which projects the pixel onto the
segment and measures to the closest point. min() of the two distances is both segments at once, and subtracting a
thickness grows the lines into capsules with round ends. The icon’s size follows the button’s height, so it scales
with the button.
The shadow: the same distance, blurred. The body’s formula is evaluated once more at a point moved up, which moves
the shape down, and cut with a wide smoothstepA soft threshold. It is 0 below the first edge, 1 above the second, and follows an S-shaped curve with flat ends in between. Smoothstep A soft threshold. It is 0 below the first edge, 1 above the second, and follows an S-shaped curve with flat ends in between.t = clamp((x − e0) / (e1 − e0), 0, 1); s = t² (3 − 2t)
Layering the colors. Each distance becomes a coverage with the same helper, fill, and the coverages pick colors
with mix in painter’s order: the border over the face, the icon over both.
An edge one pixel wide. fill doesn’t use a fixed softness. fwidth(d) is how much the distance changes between
this pixel and its neighbors, so it is the size of one pixel in the distance’s own units. A smoothstep from minus half
to plus half of that is a one-pixel anti-aliased edge however the button is scaled or zoomed. (Derivatives are covered
in their own fundamental.)
Alpha. The button is drawn with alpha blending over whatever is behind it. Its alpha is the body’s coverage plus the shadow where the body isn’t; since the shadow is black, it adds opacity but no color, and the color is divided by the alpha because the frame expects straight, not premultiplied, alpha.
The same distances give more looks for one line each: a hover glow is exp(-k * max(body, 0.0)), a pressed state
moves the shadow closer, and a focus ring is abs(body - gap) - width.
Make it in GLSL
The copied code is a complete WebGL2 fragment shader that draws the button, with its shadow, over whatever is already in the canvas.
- Draw a rectangle where the button goes, larger than the button by the shadow’s size, with a vertex shader that writes
v_uvfrom 0 to 1 across it and constants forv_position,v_normalandv_view. - Set
u_resolutionto the size, in pixels, of the area the shader’s coordinates span. The code centers its coordinates on the rectangle and corrects them byu_resolution’s aspect ratio, so for a rectangle smaller than the canvas, pass the rectangle’s own size in pixels. - Set the five float uniforms (half width and half height are in units of half the rectangle’s height) and upload the three colors decoded from sRGB to linear.
- Turn blending on with
gl.enable(gl.BLEND)andgl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA), then draw.
The shader only draws; clicks are still the page’s job. Hit-test the pointer against the same rounded box in JavaScript (or put a transparent HTML button over the canvas), and feed the hover or pressed state back as a uniform.
Make it in Godot
The shipped shader assumes HDR 2D is off (the default). With it on, make u_toLinear and u_toSrgb return their input unchanged. For a plain menu button, Godot's own StyleBoxFlat (corner radius, border, shadow) does the same without a shader; reach for this one when the shape must animate or morph.
The copied code is a complete canvas_item shader. Godot’s canvas items blend with alpha by default, so the shadow
falls on whatever is behind the node.
- Add a
ColorRect(or anyControl) to a UI scene. For the tutorial, set its anchors preset to Full Rect so its aspect matches the viewport. - Set its Material to a new
ShaderMaterialwith a newShader, and paste the code. - Adjust the half width, half height, corner radius, border width, shadow size and colors under Shader Parameters. The button updates in the editor.
- To make it clickable, make the
ColorRecta child of aButtonwhose styles are allStyleBoxEmpty. On the rect, set Mouse Filter to Ignore and turn on Visibility › Show Behind Parent, so it draws under the Button’s label instead of over it. Godot handles the input, focus and label; the shader only draws behind them. Don’t put the material on theButtonitself: its text would run through the shader too.
In a VisualShader, each distance is an Expression node holding the matching function, and the composition is plain
nodes: Max for the border, Min for the icon, SmoothStep for the shadow, a DerivativeFunc set to Sum (fwidth)
for the edges, and Mix to layer the colors.
No Unreal Engine 5 tutorial for this card yet.
No Unity tutorial for this card yet.
No Blender tutorial for this card yet.
Code
#version 300 es// 2D SDF composition → Rounded UI button, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_width; // Half width: 0.75 (0.1 to 1.4)uniform float p_height; // Half height: 0.25 (0.05 to 0.8)uniform float p_radius; // Corner radius: 0.12 (0.0 to 0.8)uniform float p_borderWidth; // Border width: 0.02 (0.0 to 0.15)uniform float p_shadowSize; // Shadow size: 0.1 (0.01 to 0.4)uniform vec3 p_faceColor; // Face color: vec3(0.27, 0.36, 0.95), sRGB; upload it decoded to linearuniform vec3 p_borderColor; // Border color: vec3(0.55, 0.62, 1.0), sRGB; upload it decoded to linearuniform vec3 p_iconColor; // Icon color: vec3(1.0, 1.0, 1.0), sRGB; upload it decoded to linearin vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};vec3 u_toSrgb(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// A box shrunk by r, then grown back by r: growing a distance field rounds every convex cornerfloat sdRoundBox(vec2 p, vec2 halfSize, float r) { vec2 q = abs(p) - halfSize + r; return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;}float sdSegment(vec2 p, vec2 a, vec2 b) { vec2 pa = p - a; vec2 ba = b - a; float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); return length(pa - ba * h);}// fwidth() is how much d changes across one pixel, so the edge is one pixel soft at any size or zoomfloat fill(float d) { float w = fwidth(d); return 1.0 - smoothstep(-0.5 * w, 0.5 * w, d);}vec4 effect(Inputs i) { vec2 p = (i.uv * 2.0 - 1.0) * vec2(i.resolution.x / i.resolution.y, 1.0); vec2 halfSize = vec2(p_width, p_height); float r = min(p_radius, min(halfSize.x, halfSize.y)); float body = sdRoundBox(p, halfSize, r); // Subtraction: the body without a copy of itself shrunk by the border width, which leaves an inner stroke float border = max(body, -(body + p_borderWidth)); // Union: two capsules meeting at the tip make a chevron float iconSize = 0.4 * p_height; vec2 tip = vec2(0.4 * iconSize, 0.0); float upper = sdSegment(p, vec2(-0.3 * iconSize, iconSize), tip); float lower = sdSegment(p, vec2(-0.3 * iconSize, -iconSize), tip); float icon = min(upper, lower) - 0.12 * iconSize; // The same box, lower and blurred by widening the smoothstep: a drop shadow costs one more distance float shadowDistance = sdRoundBox(p - vec2(0.0, -0.4 * p_shadowSize), halfSize, r); float shadow = 0.45 * (1.0 - smoothstep(-p_shadowSize, p_shadowSize, shadowDistance)); vec3 framed = mix(p_faceColor, p_borderColor, fill(border)); vec3 face = mix(framed, p_iconColor, fill(icon)); float coverage = fill(body); // The black shadow under the face, as straight alpha: it adds opacity, and only the face adds color float alpha = coverage + shadow * (1.0 - coverage); vec3 color = face * coverage / max(alpha, 0.0001); return vec4(color, alpha);}void main() { Inputs i; i.uv = v_uv; i.position = v_position; // Interpolation shortens the vectors between vertices i.normal = normalize(v_normal); i.view = normalize(v_view); i.time = u_time; i.fragCoord = gl_FragCoord.xy; i.resolution = u_resolution; vec4 frag_out = effect(i); // The output is sRGB: clamped and encoded once, here, with no tone mapping fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), frag_out.a);}// 2D SDF composition → Rounded UI button, Godot 4. From Creative Center, MIT license.shader_type canvas_item;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded, blend_mix;uniform float width : hint_range(0.1, 1.4, 0.01) = 0.75;uniform float height : hint_range(0.05, 0.8, 0.01) = 0.25;uniform float radius : hint_range(0.0, 0.8, 0.01) = 0.12;uniform float borderWidth : hint_range(0.0, 0.15, 0.005) = 0.02;uniform float shadowSize : hint_range(0.01, 0.4, 0.01) = 0.1;uniform vec3 faceColor : source_color = vec3(0.27, 0.36, 0.95);uniform vec3 borderColor : source_color = vec3(0.55, 0.62, 1.0);uniform vec3 iconColor : source_color = vec3(1.0, 1.0, 1.0);struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};// Godot's 2D renderer works in sRGB (HDR 2D off, the default), so the frame decodes color params and sRGB// textures to linear itself and encodes the result. With HDR 2D on, make these return c unchanged.vec3 u_toLinear(vec3 c) { return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));}vec3 u_toSrgb(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// A box shrunk by r, then grown back by r: growing a distance field rounds every convex cornerfloat sdRoundBox(vec2 p, vec2 halfSize, float r) { vec2 q = abs(p) - halfSize + r; return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;}float sdSegment(vec2 p, vec2 a, vec2 b) { vec2 pa = p - a; vec2 ba = b - a; float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); return length(pa - ba * h);}// fwidth() is how much d changes across one pixel, so the edge is one pixel soft at any size or zoomfloat fill(float d) { float w = fwidth(d); return 1.0 - smoothstep(-0.5 * w, 0.5 * w, d);}vec4 effect(Inputs i) { vec2 p = (i.uv * 2.0 - 1.0) * vec2(i.resolution.x / i.resolution.y, 1.0); vec2 halfSize = vec2(width, height); float r = min(radius, min(halfSize.x, halfSize.y)); float body = sdRoundBox(p, halfSize, r); // Subtraction: the body without a copy of itself shrunk by the border width, which leaves an inner stroke float border = max(body, -(body + borderWidth)); // Union: two capsules meeting at the tip make a chevron float iconSize = 0.4 * height; vec2 tip = vec2(0.4 * iconSize, 0.0); float upper = sdSegment(p, vec2(-0.3 * iconSize, iconSize), tip); float lower = sdSegment(p, vec2(-0.3 * iconSize, -iconSize), tip); float icon = min(upper, lower) - 0.12 * iconSize; // The same box, lower and blurred by widening the smoothstep: a drop shadow costs one more distance float shadowDistance = sdRoundBox(p - vec2(0.0, -0.4 * shadowSize), halfSize, r); float shadow = 0.45 * (1.0 - smoothstep(-shadowSize, shadowSize, shadowDistance)); vec3 framed = mix(u_toLinear(faceColor), u_toLinear(borderColor), fill(border)); vec3 face = mix(framed, u_toLinear(iconColor), fill(icon)); float coverage = fill(body); // The black shadow under the face, as straight alpha: it adds opacity, and only the face adds color float alpha = coverage + shadow * (1.0 - coverage); vec3 color = face * coverage / max(alpha, 0.0001); return vec4(color, alpha);}void fragment() { Inputs i; // UV starts at the top-left; the effect expects GLSL's bottom-left i.uv = vec2(UV.x, 1.0 - UV.y); // A flat quad facing the viewer, the same as the site's 2D preview i.position = vec3(i.uv * 2.0 - 1.0, 0.0); i.normal = vec3(0.0, 0.0, 1.0); i.view = vec3(0.0, 0.0, 1.0); i.time = TIME; // FRAGCOORD's origin is the top-left too i.resolution = 1.0 / SCREEN_PIXEL_SIZE; i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y); vec4 frag_out = effect(i); COLOR = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), frag_out.a);}No Unreal Engine 5 code for this card yet.
No Unity code for this card yet.
No Blender code for this card yet.
Further reading
- 2D distance functions (opens in a new tab)Article
- Distance functions (opens in a new tab)Article
- Improved alpha-tested magnification for vector textures and special effects (opens in a new tab)Paper
- fwidth (opens in a new tab)Reference
- Shapes (opens in a new tab)Book
- CanvasItem shaders (opens in a new tab)Docs