Pill Slider

A stylized rounded-pill progress bar / slider fill shader for Godot 4. Fully self-contained in a single canvas_item shader — no scripting required to render, only to drive the fill value.

Features

  • Rounded “pill” track shape (SDF-based, clean anti-aliasing, no texture needed)
  • Two-color gradient fill (e.g. blue → green) that grows with the value
  • Soft pulsing glow at the edge of the fill, animated automatically via TIME
  • Circular handle marker that stays perfectly round regardless of the node’s width/height ratio (via the slider_aspect uniform)
  • Every color and size is exposed as a uniform — fully themeable from the inspector

How to use

  1. Apply the shader to a ColorRect sized to your slider (keep its color at white).
  2. Set slider_aspect to node_width / node_height so the handle renders as a circle.
  3. Drive fill_ratio (0–1) from script, e.g. on an HSlider‘s value_changed signal.

An optional bridge script is included that does step 2 and 3 automatically if you’d rather not wire it up yourself — just attach it to your HSlider and assign the ShaderMaterial in the inspector.

 

No hover/press logic is baked in on purpose — it’s meant to be dropped onto any slider and driven purely by its value, so it never fights with your existing UI feedback (button color changes, sounds, etc.).

GDSCRIPT:

extends HSlider
## Optional bridge script for pill_slider.gdshader.
## Attach to an HSlider, assign a ShaderMaterial using pill_slider.gdshader
## to `shader_material` in the inspector, and this will keep the shader's
## `fill_ratio` and `slider_aspect` in sync automatically.

@export var shader_material: ShaderMaterial
@export var fill_grow_duration: float = 0.12

var bar: ColorRect
var _tween: Tween


func _ready() -> void:
	if not shader_material:
		push_error("pill_slider_bridge: assign a ShaderMaterial in the inspector.")
		return

	bar = ColorRect.new()
	bar.color = Color.WHITE
	bar.material = shader_material
	bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
	bar.z_index = 1
	add_child(bar)

	value_changed.connect(_on_value_changed)
	resized.connect(_sync_size)

	call_deferred("_sync_size")


func _sync_size() -> void:
	bar.size = size
	if size.y > 0.0:
		shader_material.set_shader_parameter("slider_aspect", size.x / size.y)
	_on_value_changed(value)


func _on_value_changed(_value: float) -> void:
	var ratio: float = (value - min_value) / max(0.001, max_value - min_value)
	if _tween:
		_tween.kill()
	_tween = create_tween()
	_tween.tween_property(shader_material, "shader_parameter/fill_ratio", ratio, fill_grow_duration)
Shader code
shader_type canvas_item;

/*
	Pill Slider
	-----------
	A stylized progress/slider fill shader: rounded "pill" shaped track,
	a color gradient fill that grows with `fill_ratio`, a soft pulsing
	glow at the fill's edge, and a clean circular handle that always
	stays round regardless of the node's width/height ratio.

	USAGE
	-----
	1. Apply this shader to a ColorRect (or any CanvasItem) that covers
	   the area of your slider. The ColorRect's `color` should stay
	   white (1,1,1,1) so it doesn't tint the shader's output.
	2. Set `slider_aspect` to (node_width / node_height) so the handle
	   renders as a perfect circle. Recalculate it whenever the node
	   is resized.
	3. Drive `fill_ratio` (0.0 - 1.0) from script to represent your
	   slider's current value, e.g.:

	   var ratio: float = (value - min_value) / (max_value - min_value)
	   material.set_shader_parameter("fill_ratio", ratio)

	   Animate it with a Tween for a smooth transition.

	All other motion (the edge glow pulse) runs automatically off TIME,
	no extra script logic required.
*/

// ---------------------------------------------------------------------
// Track (background pill shape)
// ---------------------------------------------------------------------
uniform vec4 track_color : source_color = vec4(0.09, 0.09, 0.11, 1.0);
uniform vec4 track_border_color : source_color = vec4(0.0, 0.0, 0.0, 0.5);
uniform float corner_radius : hint_range(0.0, 0.5) = 0.4; // in "height units", 0.5 = full pill
uniform float border_width : hint_range(0.0, 0.1) = 0.05;

// ---------------------------------------------------------------------
// Fill gradient
// ---------------------------------------------------------------------
uniform vec4 color_min : source_color = vec4(0.35, 0.55, 0.95, 1.0); // color at min_value
uniform vec4 color_max : source_color = vec4(0.55, 0.85, 0.55, 1.0); // color at max_value
uniform float fill_ratio : hint_range(0.0, 1.0) = 0.35; // drive this from script
uniform float edge_softness : hint_range(0.0, 0.2) = 0.015;

// ---------------------------------------------------------------------
// Edge glow (pulses gently at the tip of the fill, purely time-driven)
// ---------------------------------------------------------------------
uniform float tip_glow_width : hint_range(0.0, 0.2) = 0.03;
uniform float tip_glow_strength : hint_range(0.0, 0.5) = 0.15;
uniform float tip_pulse_speed : hint_range(0.0, 5.0) = 1.2;

// ---------------------------------------------------------------------
// Handle (always circular, independent of node aspect ratio)
// ---------------------------------------------------------------------
uniform vec4 handle_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
uniform vec4 handle_outline_color : source_color = vec4(0.15, 0.15, 0.18, 0.9);
uniform float handle_radius : hint_range(0.0, 0.5) = 0.32; // in "height units"
uniform float handle_outline_width : hint_range(0.0, 0.1) = 0.03;
uniform float handle_shadow_strength : hint_range(0.0, 1.0) = 0.25;

// Node width / node height. MUST be updated from script whenever the
// node is resized, otherwise the handle will render as an ellipse.
uniform float slider_aspect : hint_range(1.0, 100.0) = 10.0;


// Signed distance field for a rounded box, centered at origin.
float rounded_box_sdf(vec2 point, vec2 half_size, float radius) {
	vec2 q = abs(point) - half_size + radius;
	return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
}

void fragment() {
	// --- Pill-shaped track mask (anti-aliased rounded rect) ---
	vec2 p = (UV - 0.5) * vec2(slider_aspect, 1.0);
	vec2 half_size = vec2(slider_aspect * 0.5, 0.5);
	float dist_to_edge = rounded_box_sdf(p, half_size, corner_radius);

	float shape_mask = 1.0 - smoothstep(0.0, 0.02, dist_to_edge);
	float border_mask = smoothstep(-border_width, -border_width + 0.02, dist_to_edge)
					   - smoothstep(0.0, 0.02, dist_to_edge);

	// --- Fill gradient + edge glow ---
	vec4 grad = mix(color_min, color_max, UV.x);
	float filled = 1.0 - smoothstep(fill_ratio - edge_softness, fill_ratio + edge_softness, UV.x);

	float dist_to_tip = abs(UV.x - fill_ratio);
	float pulse = 0.5 + 0.5 * sin(TIME * tip_pulse_speed);
	float tip_glow = smoothstep(tip_glow_width, 0.0, dist_to_tip) * (0.3 + 0.7 * pulse) * tip_glow_strength;

	vec3 rgb = mix(track_color.rgb, grad.rgb + tip_glow, filled);
	rgb = mix(rgb, track_border_color.rgb, border_mask * track_border_color.a);

	float alpha = shape_mask;

	// --- Handle: circular regardless of node aspect ratio ---
	vec2 centered_uv = vec2((UV.x - fill_ratio) * slider_aspect, UV.y - 0.5);
	float dist_to_center = length(centered_uv);

	float shadow = smoothstep(handle_radius + 0.12, handle_radius, dist_to_center) * handle_shadow_strength;
	rgb = mix(rgb, vec3(0.0), shadow * 0.4);

	float outline_mask = smoothstep(handle_radius + handle_outline_width, handle_radius, dist_to_center)
					   - smoothstep(handle_radius, handle_radius - handle_outline_width, dist_to_center);
	float fill_mask = smoothstep(handle_radius, handle_radius - handle_outline_width * 0.5, dist_to_center);

	rgb = mix(rgb, handle_outline_color.rgb, outline_mask * handle_outline_color.a);
	rgb = mix(rgb, handle_color.rgb, fill_mask * handle_color.a);
	alpha = max(alpha, max(outline_mask * handle_outline_color.a, fill_mask * handle_color.a));

	COLOR = vec4(rgb, alpha);
}
Live Preview
Tags
slider
The shader code and all code snippets in this post are under CC0 license and can be used freely without the author's permission. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

More from SEED

guest

0 Comments
Oldest
Newest Most Voted