Featured resource
Tech Upskilling Playbook 2025
Tech Upskilling Playbook

Build future-ready tech teams and hit key business milestones with seven proven plays from industry leaders.

Learn more
  • Labs icon Lab
  • Core Tech
Labs

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.

Labs

Path Info

Level
Clock icon Intermediate
Duration
Clock icon 47m
Last updated
Clock icon Aug 21, 2025

Contact sales

By filling out this form and clicking submit, you acknowledge our privacy policy.

Table of Contents

  1. 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.
  2. 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 in ctx 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 and ellipse method. Normally, arc is a slightly simpler choice, but using ellipse 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, use 0 (radians) here.
    • startAngle: The ellipse function is capable of making arcs, not just whole ellipses. But since you want a whole circle/ellipse, set this to 0 (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 to 2 * Math.PI.

    Unlike other methods, like fillRect, calling ellipse 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 call ctx.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.

  3. 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 or setInterval, 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, but Math.sin(timestamp) will always return a value between -1 and 1. You can divide or multiply timestamp to control the speed, then divide or multiply the return value of Math.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 the centerY and radius 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 (though centerX 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 and ctx.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.

  4. 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 the data property (an ImageData 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 the target 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 the fill="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.

  5. 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, and ctxColor belonging to each ellipse using an ellipses array, outside the step callback. You won't need var 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.
    • floor returns the largest integer less than or equal to its input.
      • Above, this means each ctxColor may be any valid color array index.

    Since ctxColor is now ellipse-specific and no longer a var, setNextCtxFillStyle won't work anymore. It will need to become setCtxFillStyleAndGetNext and:

    1. To take ctxColor as a parameter.
    2. To return ctxColor so the caller can store the result as the new ellipse-specific ctxColor (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 over ellipses:

    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 over var to ensure the block-scoped variables are used just for the current canvas ellipse and don't interfere with their var counterparts in the surrounding function scope, which are still used for the SVG ellipse. The ctx.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 that ctx.ellipse doesn't return one?

    The correct object is a Path2D. Instead of calling ctx.beginPath and ctx.ellipse, you can instantiate a Path2D object and then call its ellipse 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 and stroke, the drawing context will no longer have a path ready, so they'll need you to pass them your stored path:

    ctx.fill(path)
    ctx.stroke(path)
    

    With a .path member on all your ellipse array values, your click handler can now iterate and call isPointInPath:

    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 the ellipses 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 your step 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 initial ellipses 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 use setAttributeNS(null, ..., ...) after that, but for most practical applications, the simpler setAttribute 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 to svgEllipse to change its attributes in your step callback, hence its storage as a property of ellipses[i]. As for svgColor, instead of being a direct property of ellipses[i], it's attached to svgEllipse for access in the click handler.

    In the step callback, the single-ellipse code can join the loop, minus the declarations of centerY, radiusX, and radiusY, since those values will now come from ellipses[i] at the top of the loop. Since svgEllipse no longer exists in the outer scope, it must come from ellipses[i] like the others.

    With that, the svgEllipse.setAttribute calls in the loop will work, but they're incomplete:

    1. There's no HTML declaration setting cx anymore, so it must be set to centerX.
    2. Likewise, to match the "color from the beginning" canvas behavior of the previous task, fill must be set to colors[svgEllipse.svgColor].

    Finally, setNextEllipseFillAttribute refers to the now-removed outer-scope svgColor. It will need to get it from target 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 on tagName === '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!

Kevin has 25+ years in full-stack development. Now he's focused on PostgreSQL and JavaScript. He's also used Haxe to create indie games, after a long history in desktop apps and Perl back ends.

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.