This project is a great intro to custom WebGL shaders, SVG stroke animation, and fragment shader effects—perfect for portfolios, backgrounds, or interactive sites.
Let’s build it step by step. 🧠
🔹 Step 1: HTML & Canvas Setup
We start with a basic HTML5 document containing a <canvas>
and an inline <svg>
element. Here's our foundational structure:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Heart Pulse - Tech Talker 360</title>
</head>
<body>
</body>
</html>
🔍 Explanation
<!DOCTYPE html>
and<html>
define the document.- The
<head>
section sets the character encoding and title of the page.
Let’s move to styling the background and animation.
🔹 Step 2: Global Styles for Layout and Theme
/* Global Styles */
* {
margin: 0;
padding: 0;
overflow: hidden;
}
body {
background: #1d1d1d;
}
🔍 Explanation
margin: 0; padding: 0;
removes default spacing.overflow: hidden;
hides scrollbars.background: #1d1d1d;
gives a dark canvas to enhance the glow effect.
Let’s move to styling the SVG heart animation.
🔹 Step 3: SVG Animation Styling
/* SVG Container */
svg {
height: 550px;
width: 745px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
filter: drop-shadow(0 0 25px #eb07b2);
}
/* Path Animation */
svg .pathdraw {
stroke: #e20cbe;
fill: transparent;
stroke-width: 6px;
animation: animate 1.5s linear infinite;
stroke-dasharray: 2000;
stroke-dashoffset: 2000;
}
@keyframes animate {
to {
stroke-dashoffset: 0;
opacity: 0;
}
}
🔍 Explanation
- The SVG is centered and given a pink glow with
drop-shadow
. - The
stroke-dasharray
andstroke-dashoffset
combo animates the line "drawing." - The keyframe reduces the dash offset, making the stroke animate like a heartbeat.
Now let’s look at the body and SVG structure.
🔹 Step 4: Canvas and SVG Markup
<canvas id="canvas" width="2500" height="1000"></canvas>
<svg width="1000" height="800" xmlns="http://www.w3.org/2000/svg">
<g class="pathdraw" id="Layer_1">
<path id="svg_1"
d="m162.5,299.2l142.5,-0.2l8,-23l11,23l34,0l14,-109l14,226l12,-118l30,0l5,-15l10,0l7,-16l10,31l155,0" />
</g>
</svg>
🔍 Explanation
<canvas id="canvas">
is where the WebGL magic happens.- The
<svg>
defines a heartbeat-like polyline path animated using CSS. - The path ID and class
.pathdraw
are targeted in the CSS for animation.
Now comes the powerful part — WebGL and shaders!
🔹 Step 5: Canvas and WebGL Initialization
We now begin the <script>
section with some essential canvas setup:
var canvas = document.getElementById("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
var gl = canvas.getContext('webgl');
if (!gl) {
console.error("Unable to initialize WebGL.");
}
var time = 0.0;
🔍 Explanation
- We grab the
<canvas>
and make it full screen. - Then we initialize WebGL, the low-level graphics API.
- A
time
variable will later animate the shader uniformly.
Now let’s define the GLSL shader sources.
🔹 Step 6: Vertex Shader Code
var vertexSource = `
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`;
🔍 Explanation
- This simple vertex shader passes through vertex positions directly.
- It’s 2D (
vec2
), and outputs a 4D position required by WebGL.
Next is the heavier part: the fragment shader.
🔹 Step 7: Fragment Shader Code (Part 1)
This long fragment shader handles the glowing heart effects.
precision highp float;
uniform float width;
uniform float height;
vec2 resolution = vec2(width, height);
uniform float time;
#define POINT_COUNT 8
vec2 points[POINT_COUNT];
const float speed = -0.5;
const float len = 0.25;
float intensity = 1.3;
float radius = 0.008;
🔍 Explanation
- We're working with high precision floats for smooth rendering.
POINT_COUNT
defines how many key points we’ll use for the heart trail.- Constants like
speed
,intensity
, andradius
control the glow behavior.
Now let’s define helper functions.
🔹 Step 8: Fragment Shader Code (Part 2) - Helper Functions
This part includes mathematical helpers for bezier curve and glow:
float sdBezier(vec2 pos, vec2 A, vec2 B, vec2 C){
vec2 a = B - A;
vec2 b = A - 2.0*B + C;
vec2 c = a * 2.0;
vec2 d = A - pos;
float kk = 1.0 / dot(b,b);
float kx = kk * dot(a,b);
float ky = kk * (2.0*dot(a,a)+dot(d,b)) / 3.0;
float kz = kk * dot(d,a);
float res = 0.0;
float p = ky - kx*kx;
float p3 = p*p*p;
float q = kx*(2.0*kx*kx - 3.0*ky) + kz;
float h = q*q + 4.0*p3;
if(h >= 0.0){
h = sqrt(h);
vec2 x = (vec2(h, -h) - q) / 2.0;
vec2 uv = sign(x)*pow(abs(x), vec2(1.0/3.0));
float t = uv.x + uv.y - kx;
t = clamp( t, 0.0, 1.0 );
vec2 qos = d + (c + b*t)*t;
res = length(qos);
}else{
float z = sqrt(-p);
float v = acos( q/(p*z*2.0) ) / 3.0;
float m = cos(v);
float n = sin(v)*1.732050808;
vec3 t = vec3(m + m, -n - m, n - m) * z - kx;
t = clamp( t, 0.0, 1.0 );
vec2 qos = d + (c + b*t.x)*t.x;
float dis = dot(qos,qos);
res = dis;
qos = d + (c + b*t.y)*t.y;
dis = dot(qos,qos);
res = min(res,dis);
qos = d + (c + b*t.z)*t.z;
dis = dot(qos,qos);
res = min(res,dis);
res = sqrt( res );
}
return res;
}
🔍 Explanation
- This is a math-heavy function that calculates the distance from a point to a quadratic Bézier curve.
- Essential for creating soft glowing segments between heart trail points.
- This logic determines the shortest distance between a point and the bezier segments of the heart.
- It handles both the 1 root and 3 roots cases for solving cubic equations.
- That
res
value is used to calculate how bright a point should glow.
Let’s now define the heart curve.
🔹 Step 9: Fragment Shader Code (Part 3) - Heart Curve and Glow Utility
vec2 getHeartPosition(float t){
return vec2(16.0 * sin(t) * sin(t) * sin(t),
-(13.0 * cos(t) - 5.0 * cos(2.0*t)
- 2.0 * cos(3.0*t) - cos(4.0*t)));
}
float getGlow(float dist, float radius, float intensity){
return pow(radius/dist, intensity);
}
🔍 Explanation
- The
getHeartPosition()
uses a parametric heart equation to compute X-Y points. getGlow()
is a simple inverse power function for the glow fade-out.
Now let’s define the core animation: trail segments!
🔹 Step 10: Fragment Shader Code (Part 4) - Glowing Trail Segments
float getSegment(float t, vec2 pos, float offset, float scale){
for(int i = 0; i < POINT_COUNT; i++){
points[i] = getHeartPosition(offset + float(i)*len + fract(speed * t) * 6.28);
}
vec2 c = (points[0] + points[1]) / 2.0;
vec2 c_prev;
float dist = 10000.0;
for(int i = 0; i < POINT_COUNT-1; i++){
c_prev = c;
c = (points[i] + points[i+1]) / 2.0;
dist = min(dist, sdBezier(pos, scale * c_prev, scale * points[i], scale * c));
}
return max(0.0, dist);
}
🔍 Explanation
- This loop computes a smooth Bezier chain from multiple heart points.
- The shader uses this to draw and animate a glowing trail along the heart’s shape.
fract(speed * t)
creates continuous movement by looping the curve.
Let’s wrap up with the main()
function!
🔹 Step 11: Fragment Shader Code (Part 5) - main()
Function
void main(){
vec2 uv = gl_FragCoord.xy/resolution.xy;
float widthHeightRatio = resolution.x/resolution.y;
vec2 centre = vec2(0.5, 0.5);
vec2 pos = centre - uv;
pos.y /= widthHeightRatio;
pos.y += 0.02;
float scale = 0.000015 * height;
float t = time;
float dist = getSegment(t, pos, 0.0, scale);
float glow = getGlow(dist, radius, intensity);
vec3 col = vec3(0.0);
col += 10.0 * vec3(smoothstep(0.003, 0.001, dist));
col += glow * vec3(1.0, 0.05, 0.3);
dist = getSegment(t, pos, 3.4, scale);
glow = getGlow(dist, radius, intensity);
col += 10.0 * vec3(smoothstep(0.003, 0.001, dist));
col += glow * vec3(0.1, 0.4, 1.0);
col = 1.0 - exp(-col);
col = pow(col, vec3(0.4545));
gl_FragColor = vec4(col, 1.0);
}
🔍 Explanation
uv
is normalized screen coordinates.- Two segments are drawn: one pink, one blue, both with a glowing trail.
smoothstep()
softens the glow edges.tone mapping
andgamma correction
enhance brightness and realism.- Finally, the result is written to
gl_FragColor
.
That finishes our shader! Now let’s hook it up in JavaScript.
🔹 Step 12: Wrapping the Fragment Shader
Now that we’ve defined all parts of the fragment shader across Steps 7 through 11, we must properly encapsulate the entire shader code into a single fragmentSource
string. This is essential for the shader to compile and run correctly.
✅ Correct Final Declaration
var fragmentSource = `
precision highp float;
uniform float width;
uniform float height;
vec2 resolution = vec2(width, height);
uniform float time;
#define POINT_COUNT 8
vec2 points[POINT_COUNT];
const float speed = -0.5;
const float len = 0.25;
float intensity = 1.3;
float radius = 0.008;
float sdBezier(vec2 pos, vec2 A, vec2 B, vec2 C){
// [ ...Full content from Step 8... ]
}
vec2 getHeartPosition(float t){
return vec2(16.0 * sin(t) * sin(t) * sin(t),
-(13.0 * cos(t) - 5.0 * cos(2.0*t)
- 2.0 * cos(3.0*t) - cos(4.0*t)));
}
float getGlow(float dist, float radius, float intensity){
return pow(radius/dist, intensity);
}
float getSegment(float t, vec2 pos, float offset, float scale){
// [ ...Full content from Step 10... ]
}
void main(){
// [ ...Full content from Step 11... ]
}
`;
🔍 Explanation
- All logic from Steps 7 to 11 is combined here into a single multiline string literal.
- This string is passed to the WebGL compiler to define the fragment shader program.
- Ensure that backticks ` are used and there are no syntax errors when pasting the complete shader code.
By structuring the fragmentSource
like this, we ensure that the entire shader is valid and ready for use in WebGL.
🔹 Step 13: Resize Listener for Canvas
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform1f(widthHandle, window.innerWidth);
gl.uniform1f(heightHandle, window.innerHeight);
}
🔍 Explanation
- Makes sure the canvas and WebGL viewport resize properly with the window.
- Uniforms are updated so the shader always knows the screen resolution.
Next up: compiling and binding the shaders!
🔹 Step 14: Shader Compilation Utilities
function compileShader(shaderSource, shaderType) {
var shader = gl.createShader(shaderType);
gl.shaderSource(shader, shaderSource);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw "Shader compile failed with: " + gl.getShaderInfoLog(shader);
}
return shader;
}
function getAttribLocation(program, name) {
var attributeLocation = gl.getAttribLocation(program, name);
if (attributeLocation === -1) {
throw 'Cannot find attribute ' + name + '.';
}
return attributeLocation;
}
function getUniformLocation(program, name) {
var attributeLocation = gl.getUniformLocation(program, name);
if (attributeLocation === -1) {
throw 'Cannot find uniform ' + name + '.';
}
return attributeLocation;
}
🔍 Explanation
- These helper functions make shader setup easier.
- They ensure meaningful errors if a uniform or attribute is missing.
We’re ready to create the program and buffers!
🔹 Step 15: Create Shaders and Program
var vertexShader = compileShader(vertexSource, gl.VERTEX_SHADER);
var fragmentShader = compileShader(fragmentSource, gl.FRAGMENT_SHADER);
var program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);
🔍 Explanation
- Vertex and fragment shaders are compiled and linked into a program.
gl.useProgram()
activates it for rendering.
Next, let’s define the full-screen rectangle that covers the canvas.
🔹 Step 16: Set Up Geometry and Buffers
var vertexData = new Float32Array([
-1.0, 1.0,
-1.0, -1.0,
1.0, 1.0,
1.0, -1.0
]);
var vertexDataBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexDataBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertexData, gl.STATIC_DRAW);
var positionHandle = getAttribLocation(program, 'position');
gl.enableVertexAttribArray(positionHandle);
gl.vertexAttribPointer(
positionHandle,
2,
gl.FLOAT,
false,
2 * 4,
0
);
🔍 Explanation
- We define a simple rectangle made of 2 triangles (a triangle strip) that fills the canvas.
- It’s bound to the shader's
position
attribute.
Now we pass in the screen dimensions as uniforms.
🔹 Step 17: Uniform Handles for Shader
var timeHandle = getUniformLocation(program, 'time');
var widthHandle = getUniformLocation(program, 'width');
var heightHandle = getUniformLocation(program, 'height');
gl.uniform1f(widthHandle, window.innerWidth);
gl.uniform1f(heightHandle, window.innerHeight);
🔍 Explanation
- These uniforms are used inside the shader to scale the heart correctly to the screen.
time
will be updated every frame to animate the glow.
Time for the draw loop!
🔹 Step 18: Animation Loop (draw function)
var lastFrame = Date.now();
var thisFrame;
function draw() {
thisFrame = Date.now();
time += (thisFrame - lastFrame) / 1000;
lastFrame = thisFrame;
gl.uniform1f(timeHandle, time);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(draw);
}
draw();
🔍 Explanation
- Each frame, the time is updated and passed to the shader.
gl.drawArrays(...)
redraws the full-screen glow effect.requestAnimationFrame(draw)
creates a smooth infinite loop.
💡 Combine It All
Once you've walked through and added all these snippets, you'll see the full effect: a beautiful glowing heart pulse that animates gracefully across your screen using nothing but vanilla HTML, CSS, JavaScript, and WebGL shaders.
✅ Best Practices & Tips
- Use
requestAnimationFrame
for smooth, GPU-friendly animation. - Keep shader logic modular with small utility functions like
getGlow()
andgetSegment()
. - When building WebGL UIs, use
<canvas>
and<svg>
together for creative visual layers. - Avoid unnecessary libraries when performance and learning are your goals — this is pure WebGL magic.
📈 SEO/Marketing Tie-In
Fast, GPU-powered animations like this don’t just look good — they also enhance user engagement, reduce dependency on heavy animation libraries, and improve performance and SEO by keeping the page light and responsive.
🎯 Conclusion
That’s it — you’ve now built an animated glowing heart pulse using SVG stroke animations and WebGL shaders!
This kind of visual project is fantastic for portfolio pieces, Valentine’s landing pages, or just deepening your understanding of low-level graphics programming.
🛠️ Got creative ideas with WebGL or canvas? Try tweaking colors, shapes, or glow styles to make it your own!
💬 Have questions or ideas? Drop them in the comments and let us know how you'd customize this project.
🌐 Stay Connected with Tech Talker 360
Want more tutorials like this?
0 Comments