- Lab
- Core Tech

Guided: Dynamic Graphics with Canvas and SVG
This lab will guide you through the parallel construction of a JavaScript-based interactive graphics demo with two different web technologies — the Canvas API and SVGs — to give you first-hand experience with their pros, cons, and other differences. The lab content assumes you're familiar with JavaScript, HTML, CSS, and basic graphics concepts like pixels and RGB values.

Path Info
Table of Contents
-
Challenge
Introduction
This lab will guide you through the parallel construction of a JavaScript-based interactive graphics demo with two different web technologies — the Canvas API and SVGs — to give you first-hand experience with their pros, cons, and other differences. The lab content assumes you're familiar with JavaScript, HTML, CSS, and basic graphics concepts like pixels.
Some notes:
- You'll do all your coding in
/index.html
, building on your solution with every task. - Per best practices, it's recommended you keep strict mode enabled by leaving
'use strict'
as the first line of the<script>
area. - If you get stuck on a task, you can consult the
solutions/
folder.
- You'll do all your coding in
-
Challenge
Basic Setup
As its name suggests, the Canvas API provides canvases that you can "paint" upon — that is, you can set the colors of their individual pixels.
To create a canvas, use the
<canvas>
HTML element, specifying its width and height in pixels:<canvas height="160" width="160"></canvas>
During development, it can help to make the boundaries of the canvas visible with some CSS:
canvas { border: 1px solid black; }
With canvases, everything else generally happens using JavaScript. First you need to get the HTML element, then to be able to draw on the canvas, you need its drawing context (commonly abbreviated
ctx
):var canvas = document.querySelector('canvas') var ctx = canvas.getContext('2d')
You can now use the
CanvasRenderingContext2D
object stored inctx
for various drawing operations, such as setting the color for all subsequent fill operations (those which draw filled-in shapes):ctx.fillStyle = 'gold'
If you want to draw a circle,
ctx
has two methods:arc
andellipse
method. Normally,arc
is a slightly simpler choice, but usingellipse
will put you in an easier position to animate your circle a certain way in later tasks:var centerX = canvas.width / 2 var centerY = canvas.height / 2 var radius = canvas.width / 3 ctx.ellipse(centerX, centerY, radius, radius, 0, 0, 2 * Math.PI)
The code above creates a canvas-centered circle by passing the same value for the x-axis radius as the y-axis radius. The last three parameters, which are required, are:
rotation
: Since rotating a circle doesn't do anything, use0
(radians) here.startAngle
: Theellipse
function is capable of making arcs, not just whole ellipses. But since you want a whole circle/ellipse, set this to0
(radians) to start the arc at the positive x-axis, i.e., pointing to the right.endAngle
: Since there are 2π radians in a full circle, set this to2 * Math.PI
.
Unlike other methods, like
fillRect
, callingellipse
on its own adds to the context's current path but doesn't actually draw anything. To close off the current path and draw a filled rendition of it, simply callctx.fill()
. As with the Canvas API, to work with SVGs, you create an HTML element (which can also benefit from a CSS border) to house them:<svg width="160" height="160"></svg>
Where a canvas needs JavaScript to render a circle or ellipse, SVGs let you add them declaratively, within the
<svg>
HTML element:<ellipse cx="50%" cy="50%" rx="33.3%" ry="33.3%" fill="gold" />
While you can't specify calculations for the center and radius values like JavaScript allows, you can specify percentages relative to the parent SVG element, as seen above.
With that, you should have the Canvas and SVG areas behaving identically. Just about anything you can do declaratively with HTML, you can do with JavaScript. That's true of SVG, too: You could also have created the
ellipse
element dynamically, but that will come in a later task. -
Challenge
Animation
It's time to bring your circle to life with a bit of animation. A naive approach might involve putting your drawing into a loop. The problem is, not all computers will execute the loop at the same speed. Even the same computer under differing levels of background load may vary its performance considerably, despite a multi-core CPU.
A slightly less naive approach might use
setTimeout
orsetInterval
, but under load, even these can cause janky visuals.The solution is to use the Web API's
requestAnimationFrame
function. Pass it a callback function to be called before the next repaint (typically happening 60 times per second). The advantage is that the callback function will be called with a timestamp, allowing you to calculate how long has passed since the last time you drew a frame. Like this, the movement of your animation won't slow down under load, even if its FPS (number of frames per second being rendered) drops.To make a loop, have your callback call
requestAnimationFrame
, then also call it once outside that to trigger the loop:requestAnimationFrame(step) // trigger the loop beginning function step(timestamp) { // do some drawing here based on the value of timestamp requestAnimationFrame(step) // continue the next step of the loop when ready }
A common technique is to store the value of
timestamp
and take the difference between the current and previous values, then base the next frame of the animation on this elapsed (or "delta") value. For example, you could move something one pixel for every 10 milliseconds that pass.Even simpler, you can use a sine (or any other periodic) function on the
timestamp
value directly, since timestamp can keep increasing forever, butMath.sin(timestamp)
will always return a value between -1 and 1. You can divide or multiplytimestamp
to control the speed, then divide or multiply the return value ofMath.sin
to change the range:var animationFactor = 10 * Math.sin(timestamp / 100)
Here,
animationFactor
will loop between -10 and 10 and back every 200π (~628) milliseconds or so. You can use that to animate the circle by changing thecenterY
andradius
variables to become base values:var centerYBase = canvas.height / 2 var radiusBase = canvas.width / 3
Inside your callback, these base values are then used in each frame to calculate current values using
animationFactor
(thoughcenterX
happens to remain static):var centerY = centerYBase + animationFactor var radiusX = radiusBase + animationFactor var radiusY = radiusBase - animationFactor
The first two lines will have the ellipse moving downward, becoming wider, and becoming shorter, then the opposite (upward, narrower, taller), in a smooth, sinusoidal loop, so long as you use these calculated values now — including the now-distinct radius values — in your
ellipse
call:ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI)
But the callback will just repeatedly draw overtop whatever's already on the canvas. To make it actually animate, clear the canvas and reset the current path before drawing:
ctx.clearRect(0, 0, canvas.width, canvas.height) ctx.beginPath()
With those lines in their proper place, your left-hand disc should be dancing.
(Note that it's not required in this lab, but calling
ctx.save()
before drawing andctx.restore()
afterward, to sandbox temporary drawing state changes, is considered a best practice in more complicated contexts.) As for SVG, it can be animated via CSS or JavaScript. Since you're already halfway there with your canvas implementation, you can stick with JavaScript to see the similarities. Feel free to comment your JavaScript as you go to show which parts apply to just canvas, just SVG, or both — and note that these will shift from task to task.To work with your SVG's nested
<ellipse>
element, get a reference to it:var svgEllipse = document.querySelector('ellipse')
You can then set its attributes within your callback to match what's changing in each frame:
svgEllipse.setAttribute('cy', centerY) svgEllipse.setAttribute('rx', radiusX) svgEllipse.setAttribute('ry', radiusY)
With that, you should once again have the Canvas and SVG areas behaving identically. A side effect of using the same calculation to animate both the canvas and the SVG is that they necessarily stay synchronized. In the next task, however, you'll intentionally avoid this.
-
Challenge
Events on a Simple Target
Suppose you want your ellipse, instead of being set with
ctx.fillStyle = 'gold'
, to cycle through different fill colors when clicked:var colors = ['gold', 'chartreuse', 'teal', 'hotpink'] var ctxColor = 0 function setNextCtxFillStyle() { ctx.fillStyle = colors[ctxColor] if (++ctxColor >= colors.length) ctxColor = 0 }
How can you wire this up to your canvas ellipse? Making it respond to pointer events may be slightly more complicated than you would expect. The main reason for this is that you can't use
addEventListener
directly on something you draw on a canvas, since your drawings aren't DOM objects. The closest you can get is a click handler on the canvas itself:canvas.addEventListener('click', canvasClickHandler)
That's a start, but how can you tell whether the user clicked the circle or the empty area of the canvas? There are a couple options: Calculating a "hitbox" or checking the color of the pixel that was clicked.
A hitbox (interaction-enabled area) doesn't necessarily have to match the exact shape of its corresponding visual — a common technique in, for example, video games, is to approximate a more complicated visual figure with a simple rectangle. Since the shape in this case is an ellipse, you could, for example, use a known math formula to determine whether a given point falls within the ellipse or not, without approximating.
However, in the uncluttered environment of this example, a color check is simplest and will give you a perfectly precise result. The first step is to get the coordinates within the canvas by subtracting the canvas offset from the click event coordinates:
function canvasClickHandler({ clientX, clientY }) { var x = clientX - canvas.offsetLeft var y = clientY - canvas.offsetTop }
You can then call
ctx.getImageData
on a one-pixel-square area at these coordinates, and take thedata
property (anImageData
object) of the result:var pixel = ctx.getImageData(x, y, 1, 1).data
(It's possible for
getImageData
to throw an exception, so in a production context, a try/catch block might make sense, depending on your logging setup and expectations about the execution environment.)pixel
will now consist of an array of RGBA values, each from 0 to 255. Your filled ellipse should be the only thing with an A value of 255:if (pixel[3] === 255) setNextCtxFillStyle()
With that, clicking the ellipse on the left should cycle through your four-color palette, but clicking elsewhere within the canvas should have no effect. Responding to clicks on specific shapes is much more straightforward with SVG, since the
<ellipse>
element is a DOM object and can have a direct event listener:svgEllipse.addEventListener('click', setNextEllipseFillAttribute)
To keep the colors of the canvas and SVG ellipses independent, declare a separate variable,
svgColor = 0
, for your click handler to use. Unlike your canvas click handler, your SVG ellipse click handler doesn't need to know about coordinates — it can just use thetarget
property of the event to set the fill color:function setNextEllipseFillAttribute({ target }) { target.setAttribute('fill', colors[svgColor]) if (++svgColor >= colors.length) svgColor = 0 }
Lastly, matching the removal of the static
fillStyle
from the canvas will mean removing thefill="gold"
attribute from the SVG ellipse HTML, letting it return, initially, to its default color.With that, the two halves of your demo should once again match in terms of their behavior — albeit while tracking their click/color state separately rather than completely mirroring each other. Once again, the SVG solution was a bit shorter, thanks to being able to use a direct click handler. That said, keep in mind that certain aspects are being reused from the previous canvas-focused task, like the
colors
array. -
Challenge
Events on Independent Targets
Detecting whether a click was in one lonely ellipse was doable. What happens when there are, say, 20 randomly sized and randomly placed ellipses? To cycle an ellipse's fill color, you'll additionally have to know which ellipse was clicked.
Start with tracking the
centerX
,centerYBase
,radiusBase
, andctxColor
belonging to each ellipse using anellipses
array, outside thestep
callback. You won't needvar ctxColor
anymore, but the other three are still needed by the SVG part of the code for now.var ellipses = [] for (let i = 0; i < 20; i++) { ellipses.push({ centerX: Math.random() * canvas.width, centerYBase: Math.random() * canvas.height, radiusBase: Math.max((Math.random() * canvas.width) / 3, 10), ctxColor: Math.floor(Math.random() * colors.length), }) }
If you're not familiar with these
Math
members:random
returns a number from 0 up to (but excluding) 1.max
returns the larger of two numbers passed to it.- Above, this means each
radiusBase
will be at least 10 and at most one third of the canvas width.
- Above, this means each
floor
returns the largest integer less than or equal to its input.- Above, this means each
ctxColor
may be any validcolor
array index.
- Above, this means each
Since
ctxColor
is now ellipse-specific and no longer avar
,setNextCtxFillStyle
won't work anymore. It will need to becomesetCtxFillStyleAndGetNext
and:- To take
ctxColor
as a parameter. - To
return ctxColor
so the caller can store the result as the new ellipse-specificctxColor
(in the case of color rotation) or ignore it (in the case of a regular animation frame).
Within the
step
callback, your single-ellipse-drawing code will need to become part of a loop overellipses
:for (let i = 0; i < ellipses.length; i++) { let { centerX, centerYBase, radiusBase, ctxColor } = ellipses[i] let centerY = centerYBase + animationFactor let radiusX = Math.max(radiusBase + animationFactor, 1) let radiusY = Math.max(radiusBase - animationFactor, 1) ctx.beginPath() ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI) setCtxFillStyleAndGetNext(ctxColor) ctx.fill() ctx.stroke() }
Here,
Math.max
ensures the radiuses don't become too small in edge cases;let
is used overvar
to ensure the block-scoped variables are used just for the current canvas ellipse and don't interfere with theirvar
counterparts in the surrounding function scope, which are still used for the SVG ellipse. Thectx.stroke
call adds an outline to each ellipse to make overlapping ones of the same color easier to distinguish from one another.When it comes to determining which ellipse was clicked, it's time to upgrade your approach. Instead of checking the color, or checking a math formula for an ellipse, you can leverage a built-in function,
ctx.isPointInPath
, for this check. It takes a path and x- and y-coordinates. But where do you get a path, given thatctx.ellipse
doesn't return one?The correct object is a
Path2D
. Instead of callingctx.beginPath
andctx.ellipse
, you can instantiate aPath2D
object and then call itsellipse
method to store the ellipse shape instead of drawing it:let path = new Path2D() path.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI) ellipses[i].path = path
Then, when it comes to
fill
andstroke
, the drawing context will no longer have a path ready, so they'll need you to pass them your storedpath
:ctx.fill(path) ctx.stroke(path)
With a
.path
member on all yourellipse
array values, your click handler can now iterate and callisPointInPath
:for (let i = 0; i < 20; i++) { if (ctx.isPointInPath(ellipses[i].path, x, y)) { ellipses[i].ctxColor = setCtxFillStyleAndGetNext(ellipses[i].ctxColor) } }
Like this, you no longer need to check pixel color values from
getImageData
. (Bonus: This approach would work with more complex shapes, too.) Note that any ellipses under the cursor receive the click, not just the topmost one. In certain contexts (such as user interfaces and objects in 2D games) it can be appropriate to avoid this issue by ensuring targets don't overlap in the first place. In this particular case, since you have absolute control over the drawing order and everything being drawn, it would have been sufficient to reverse-loop over theellipses
array and exit the loop as soon as there's a hit. Other algorithms for different behavior than this are beyond the scope of this lab.This facet of the previous task further highlights the differences between the design problems involved for canvas versus SVG solutions. As you may have guessed, the ability of
<ellipse>
to have a direct click handler will mitigate the above issue entirely as you implement multiple overlapping click targets using SVG.The first step is removing the single, static
<ellipse>
element from your HTML (leaving the parent<svg ...>
element) along with the single-ellipse setup code outside yourstep
callback.To add children to the
<svg>
element dynamically, a reference to it in the outer script scope will be handy:var svg = document.querySelector('svg')
Next, much of the
ellipses
array can be reused. Following the pattern of previous tasks, you'll have the initial positions and colors match, but afterward track the SVG ellipse colors independent of their canvas counterparts. Unlike the canvas solution, where ellipse-drawing occurs each frame, the SVG ellipse creation will occur within the initialellipses
setup loop:let svgEllipse = document.createElementNS( 'http://www.w3.org/2000/svg', 'ellipse' ) svg.appendChild(svgEllipse)
You have to use
createElementNS
with the first argument as shown, otherwise the element won't function as SVG — it'll function in the parent HTML namespace, and therefore be interpreted as broken or invalid HTML. Technically, you should usesetAttributeNS(null, ..., ...)
after that, but for most practical applications, the simplersetAttribute
will work:svgEllipse.setAttribute('stroke', 'black') svgEllipse.svgColor = ellipses[i].ctxColor svgEllipse.addEventListener('click', setNextEllipseFillAttribute) ellipses[i].svgEllipse = svgEllipse
Here,
setAttribute
sets the stroke, since in SVG it defaults to none, unlike the black default in canvas. You'll need a reference tosvgEllipse
to change its attributes in yourstep
callback, hence its storage as a property ofellipses[i]
. As forsvgColor
, instead of being a direct property ofellipses[i]
, it's attached tosvgEllipse
for access in the click handler.In the
step
callback, the single-ellipse code can join the loop, minus the declarations ofcenterY
,radiusX
, andradiusY
, since those values will now come fromellipses[i]
at the top of the loop. SincesvgEllipse
no longer exists in the outer scope, it must come fromellipses[i]
like the others.With that, the
svgEllipse.setAttribute
calls in the loop will work, but they're incomplete:- There's no HTML declaration setting
cx
anymore, so it must be set tocenterX
. - Likewise, to match the "color from the beginning" canvas behavior of the previous task,
fill
must be set tocolors[svgEllipse.svgColor]
.
Finally,
setNextEllipseFillAttribute
refers to the now-removed outer-scopesvgColor
. It will need to get it fromtarget
instead:var svgColor = target.svgColor
And it will need to save the newly-rotated color value so
step
can pick it up on the next frame:target.svgColor = svgColor
With that, the canvas and SVG behaviors will again match — except for the SVG click handler only changing the topmost ellipse's color as discussed above. It was easier with SVG to make it so that only the topmost ellipse reacted to a click. What if you preferred the canvas behavior? In this case, you could use
document.elementsFromPoint(clientX, clientY)
in the event handler, then filter ontagName === 'ellipse'
, and run the handler on all results instead of just the current target:function setNextEllipseFillAttribute({ clientX, clientY }) { for (let target of document.elementsFromPoint(clientX, clientY)) { let svgColor = target.svgColor if (++svgColor >= colors.length) svgColor = 0 target.svgColor = svgColor } }
This handler could then be attached to just the
<svg>
element as a whole, instead of individual<ellipse>
elements, similar to the canvas approach.--
Congratulations on completing this lab!
What's a lab?
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Provided environment for hands-on practice
We will provide the credentials and environment necessary for you to practice right within your browser.
Guided walkthrough
Follow along with the author’s guided walkthrough and build something new in your provided environment!
Did you know?
On average, you retain 75% more of your learning if you get time for practice.