Skip to content
Shape · 2D SDF compositionIntermediate

Rounded UI button

A pill-shaped button with an inner border, a chevron icon and a soft drop shadow, all cut from signed distances.

UVSurface
Open in Playground
  1. 01GoalShape
  2. 02Technique2D SDF composition
  3. 03ExecutionRounded UI button
Full-screen quad

Parameters

0.75
0.25
0.12
0.020
0.10
#455cf2
#8c9eff
#ffffff

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.

  1. lengthExpressionlengthlengthlength
    UV

    01 Rounded box distance

  2. maxMaxmaxmaxmax

    02 Subtract: inner border

  3. minMinminminmin

    03 Union: chevron

  4. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep

    04 Soft shadow

  5. mixMixmixmixmix

    05 Layer the colors

  6. fwidthDerivativeFuncfwidthfwidthfwidth

    06 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 distances means four short formulas, combined with the operators of SDF composition, and every edge stays exactly one pixel soft at any size.

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 smoothstep instead of a one-pixel one. A wide ramp across a distance is a blur for free: no samples of neighboring pixels, just a softer threshold. Its strength is scaled to 0.45 so it reads as a shadow, not a second button.

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.

  1. Draw a rectangle where the button goes, larger than the button by the shadow’s size, with a vertex shader that writes v_uv from 0 to 1 across it and constants for v_position, v_normal and v_view.
  2. Set u_resolution to the size, in pixels, of the area the shader’s coordinates span. The code centers its coordinates on the rectangle and corrects them by u_resolution’s aspect ratio, so for a rectangle smaller than the canvas, pass the rectangle’s own size in pixels.
  3. 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.
  4. Turn blending on with gl.enable(gl.BLEND) and gl.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.

  1. Add a ColorRect (or any Control) to a UI scene. For the tutorial, set its anchors preset to Full Rect so its aspect matches the viewport.
  2. Set its Material to a new ShaderMaterial with a new Shader, and paste the code.
  3. Adjust the half width, half height, corner radius, border width, shadow size and colors under Shader Parameters. The button updates in the editor.
  4. To make it clickable, make the ColorRect a child of a Button whose styles are all StyleBoxEmpty. 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 the Button itself: 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

rounded-button.fragGLSL
#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);}
rounded-button.gdshaderGodot shader
// 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