DEV Community

Cover image for Build a Dartboard with Web Components & HTML Canvas🎯
Stephen Marsh
Stephen Marsh

Posted on

Build a Dartboard with Web Components & HTML Canvas🎯

Pre-Requisites NodeJS, NPM, a Modern Browser
GitHub / Storybook

There are many great resources out there to get started with Web Components and several for how to use them with SVG. If you are working with the HTML Canvas you may find examples hard to come by. In this article we’ll demonstrate some of the nuances involved in creating a production grade Web Component using vanilla JS and HTML Canvas and techniques for handling customization and interactivity.

You will learn how to

  • Create a production Web Component project
  • Render an image to the Canvas
  • Auto size the element
  • Customize the style of the component
    The top half of a dartboard
    Our end product, a dartboard

Why Web Components?

Web components are a collection of web standards which allow developers to create custom reusable components that can be used in any modern browser with no dependencies. Though not a substitute for full frameworks like React or Angular, web components are perfect for creating isolated, customizable, leaf-node design elements. Depending on who you ask they are either the future of the web or its greatest threat!

Here’s what a simple vanilla Web Component looks like.

// dartboard.js
export class Dartboard extends HTMLElement {
  connectedCallback() {
    this.textContent = 'Hello, Dartboard!';
  }
}

customElements.define('my-darboard', Dartboard);
Enter fullscreen mode Exit fullscreen mode
<!DOCTYPE html>
<html lang="en">
  <body>
    <script type="module" src="dartboard.js">
    <my-dartboard></my-dartboard>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Why HTML Canvas?

While SVG is better suited for many graphics use-cases, the Canvas is optimal for high-performance rendering of animations or interactive elements. Canvas works well when you need to handle frequent updates of a large number of elements, like with animations, gaming, or high frequency financial visualizations.

Getting Started

Web Components require no special tooling — just a .html and .js file. However, if you’re developing for production, tools like testing, linting, and packaging are essential. Fortunately for us, the good people at Open Web Components have created a toolset to set up all the essentials. We’ll be using their project generator to create a starter component.

Open Web Components is a community-effort, independent of any framework or company. We are based on open-source tools and services.
— open-wc.org

To begin, open a command prompt and run:

npm init @open-wc   
Enter fullscreen mode Exit fullscreen mode

Follow the prompts to setup your project.

What would you like to do today? 
  Scaffold a new project 
What would you like to scaffold?
  Web Component
What would you like to add?
  (*) Linting (eslint & prettier)
  (*) Testing (web-test-runner)
  (*) Demoing (storybook)
Would you like to use typescript?
  Yes
What is the tag name of your web component? 
  my-dartboard

...

You are all set up now!

All you need to do is run:
  cd my-dartboard
  npm run start
Enter fullscreen mode Exit fullscreen mode

Once the script is complete the generator will print out the folder structure of your project. This includes a starter Web Component along with scripts for building, testing, and linting. It also creates a script for running a demo page using Storybook. You can find out more about these features in the Open Web Components documentation. For now we’ll stick with running the demo page locally.

cd my-dartboard
npm run start

Web Dev Server started...

  Root dir: C:\Users\Me\Documents\code\my-dartboard
  Local:    http://localhost:8000/demo/
  Network:  http://192.168.0.208:8000/demo/
Enter fullscreen mode Exit fullscreen mode

A button with the label increment. When you click it the number increments
The starter Web Component created by the project generator

You will find two files in the src folder, MyDartboard.ts which contains the class definition for our component and my-dartboard.ts which registers it with the browser. Open MyDartboard.ts and replace the contents with the following code which will create a canvas element and add it to the components Shadow DOM. The Shadow DOM is an isolated scope where our component can live without exposing the internal structure or bleeding styles to the outside page.

export class Dartboard extends HTMLElement {
  #canvas: HTMLCanvasElement;
  #shadow: ShadowRoot;

  constructor() {
    super();
    this.#shadow = this.attachShadow({ mode: 'open' });
    this.#canvas = document.createElement('canvas');
    this.#canvas.width = 600;
    this.#canvas.height = 300;
    this.#shadow.appendChild(this.#canvas);
  }
}
Enter fullscreen mode Exit fullscreen mode

Chrome DevTools showing element structure of the page with the canvas inside a Shadow DOM node
Our Canvas, snuggled safely within the Shadow DOM

Rendering the Board

To render our dartboard to the canvas we first defined the board structure in a Board object. A dartboard has a radius which is the size of the total outer circle of the board along with a list of rings and sectors that cross to make up the individual sections. Each sector has it’s own point value starting with 20, 1, 18, etc. Rings are defined by their radius from the center.

export interface Board {
  radius: number;
  rings: number[];
  sectors: number[];
}

const board: Board = {
  radius: 225,
  rings: [
    6.35,  // Double Bull
    16,    // Single Bull
    99,    // Skinny Single
    107,   // Treble
    162,   // Fat Single
    170,   // Double. Edge of scoreable area
  ],
  sectors: [
    20, 1, 18, 4, 13, 6, 10, 15, 2, 17,
    3, 19, 7, 16, 8, 11, 14, 9, 12, 5
  ]
};
Enter fullscreen mode Exit fullscreen mode

Next we’ll add a render method to our class which will use the 2D context API to draw our dartboard to the canvas. To start we’ll draw the background circle for our board using the arc function. We pass it the center point (0,0) and tell it to draw a complete circle from 0 to 2π radians. And voila, we have a dartboard!

#render () {
  const ctx = this.#canvas.getContext('2d');
  context.beginPath();
  context.arc(0, 0, 225, 0, Math.PI * 2);
  context.fillStyle = '#000';
  context.fill();
}
Enter fullscreen mode Exit fullscreen mode

A black quarter circle on white background placed at the top left
Definitely not a dartboard

Something looks a bit off. Instead of a circle we have a sort of wedge shape. We’ll need to make some adjustments, first to the center point and then to the radius.

Center Point — The Canvas’ coordinate system has the point (0,0) at the top left corner of the element with the X and Y axis pointing right and down. To center the board in the middle of the canvas we’ll need to pass a point to the arc function that is in the middle of the canvas and flip the y axis so it points up instead of down.

Two circles drawn next to each other on grids. The left is centered at 0,0 while the right is centered in the lower left box of the grid
Standard Cartesian coordinates (left) vs Canvas inverted coordinates (right)

Radius — We passed a value of 225 millimeters to the arc function because that’s the size of a standard dartboard. The Canvas doesn’t know anything about millimeters — it works in pixels. To correctly size our component we’ll need to map our board width to the width of the canvas.

To fix these issues we could manually convert the parameters using the adjustments we’ve just mentioned and pass the corrected values to the arc function. But we have many more elements to draw and doing that for every command would become tedious. Instead we can use the Canvas API to apply a translation to make the proper adjustments automatically. Let’s see how this works.

export const render = (board, context) => {
  context.save();

  // Put center point in the middle of the canvas
  const width = context.canvas?.width;
  const height = context.canvas?.height;
  context.translate(width / 2, height / 2);

  // Flip the y-axis to point up
  context.scale(1, -1);

  // Scale the board to fill the canvas
  const diameter = board.radius * 2;
  const size = Math.min(width, height);
  const scale = (size / diameter);
  context.scale(scale, scale);

  // Draw the board background
  context.beginPath();
  context.arc(0, 0, board.radius, 0, Math.PI * 2, false);
  context.fillStyle = '#000';
  context.fill();

  context.restore();
};
Enter fullscreen mode Exit fullscreen mode

A black circle centered on a white background
A dartboard, sort of

Much better! The translations we applied will stay in place for subsequent commands so we can draw the rest of the dartboard using measurements that make sense to us. We’ve also wrapped our function with a save and restore call, which let’s us save the translation state of the canvas before making changes, and restore it when we’re done.

Drawing the segments

Let’s start with drawing the single 20 section. This is the wedge at the top center of the board in the area between 107 mm and 162 mm.

Photo of a dartboard with an arrow pointing to the top center section which is the single 20 area
Single 20 section of a dartboard

// Rotate to the top of the board
const sectorWidth = (Math.PI * 2) / 20;
context.rotate((Math.PI / 2) + (sectorWidth / 2));

// Draw a wedge shape
context.beginPath();
context.moveTo(0, 0);
context.arc(0, 0, 107, 0, sectorWidth);
context.arc(0, 0, 162, sectorWidth, 0, true);
context.closePath();

// Color it in white
context.fillStyle = '#FFF';
context.fill();
Enter fullscreen mode Exit fullscreen mode

We rotate the canvas π/2 radians (90 degrees) so our starting angle is pointing straight up, then rotate an additional half sector width so the sector is centered in the middle of the y-axis. Using arc we draw a wedge shape and then fill it in solid white.

A black circle with a white 4 sided wedge in the top center where the single 20 section is on a dartboard
A dartboard with one section

Now loop through the rings and sectors to rotate the board and draw the remaining sections.

// Draw sectors of the board
const colors = ['#111', '#ffe', '#b33', '#252'];
const sectorWidth = (Math.PI * 2) / board.sectors.length;
context.rotate((Math.PI / 2) + (sectorWidth / 2));

for (let s = 0; s < board.sectors.length; s++) {
  for (let r = 2; r < board.rings.length; r++) {
    context.beginPath();
    context.moveTo(0, 0);
    context.arc(0, 0, board.rings[r], 0, sectorWidth);
    context.arc(0, 0, board.rings[r - 1], sectorWidth, 0, true);
    context.closePath();
    context.fillStyle = colors[( r % 2 * 2) + s % 2];
    context.fill();
  };
  context.rotate(-sectorWidth);
};
Enter fullscreen mode Exit fullscreen mode

A black circle with dartboard sections alternating colors red, black, white, green
Something that actually looks like a dartboard

To round out our dartboard we need to draw a few additional items. I won’t go through each of these but you can review the full render method in the project code.

A dartboard with sectors and rings labeled with numbers around the outer edge
A dartboard

Resizing the Component

Next we need to control the size of the dartboard. We will do this by applying styles to the parent my-dartboard element so that the canvas automatically matches its size to the container. Do this by adding the following styles to the constructor.

constructor() {
  ...
  this.#shadow = this.attachShadow({ mode: 'open' });
  this.#shadow.innerHTML = `
    <style>
      :host {
        display: flex;
        width: 100%;
        aspect-ratio: 1 / 1;
        box-sizing: border-box;
        user-select: none;
      }
      canvas { width: 100%; height: 100%;}
    </style>
    <canvas></canvas>
  `;
  ...
}
Enter fullscreen mode Exit fullscreen mode

A dartboard being resized by the user but not changing resolution or width

The component now scales to match its parent container, however the image looks distorted when we resize it. Think of the canvas like an image which has the dimensions of the actual underlying bitmap as well as the size it is presented on screen. Changing the width in CSS doesn’t change the actual number of pixels of the image, it just stretches them to fit a given area. To prevent our dartboard from appearing pixelated we’ll need to detect when the parent my-dartboard element changes size then set the canvas image size to match and redraw the image. We’ll do this with a ResizeObserver.

import { debounce } from './debounce';

export class Dartboard extends HTMLElement {
  constructor() {
    ...
    const resizeObserver = new ResizeObserver(
      debounce((entries: ResizeObserverEntry[]) => {
        const entry = entries.find(e => e.target === this)!;
        const box = entry.devicePixelContentBoxSize?.[0];
        const boxC = entry.contentBoxSize[0];
        const physical = (n: number) => Math.round(n * devicePixelRatio);
        this.#canvas.width = box?.inlineSize ?? physical(boxC.inlineSize);
        this.#canvas.height =  box?.blockSize ?? physical(boxC.blockSize);
        const style = getComputedStyle(this);
        const theme = createTheme(style);
      }, 300, { leading: true, trailing: true })
    );
    resizeObserver.observe(this, { box: "device-pixel-content-box" });  }
}
Enter fullscreen mode Exit fullscreen mode

Redrawing the dartboard is an expensive operation so we don’t want to run it every time the resize callback is fired. We wrap the callback in a debounce function which will wait until the the element has stopped actively resizing before redrawing the content.

It’s also important to note that we used devicePixelContentBoxSize to get the dimensions of the parent container. This property returns the dimensions in physical device pixels and will keep the image looking crisp on on High resolution monitors. Currently this feature is not supported on Safari so as a fallback we calculate it manually using devicePixelRatio.

Image of the dartboard being resized by the user, appearing distorted at first and after a pause, redrawing at full resolution
Check out the demo to try it out for yourself

Now that our dartboard automatically adjusts to the parent container we can apply most any CSS styles to it just like it was a native element. See additional examples on the demo page.

Three dartboards with different sizes, all with 1:1 aspect-ratio
width: 30em, 20em, 10em

Three dartboards with different paddings
padding: 1em, 4em, 8em

Multiple dartboards with different transformations applied
transform: rotate, rotate3D, skew, scale

Multiple dartboards with different transformations applied
filter: blur, drop-shadow, opacity, invert

Customizing Styles

To customize the styling for our dartboard we need a way pass in parameters to control how the board is rendered. We’ll do this using custom CSS variables to define a set of design tokens to allow the user to control presentation of the component.

<style>
  my-dartboard {
    --dartboard-board-bg: #283618;
    --dartboard-sector-bg-1: #606c38;
    --dartboard-sector-bg-2: #fefae0;
    --dartboard-sector-bg-3: #edbf8c;
    --dartboard-sector-bg-4: #bc6c25;
    --dartboard-wire-color: #271300;
    --dartboard-wire-shadow-show: 0;
  }
</style>
<my-dartboard></my-dartboard>
Enter fullscreen mode Exit fullscreen mode

We can pick up these values in our component using the getComputedStyles function. If a value has been set for the token we’ll use it, otherwise we’ll use a default. We then pass these values into our render function to use instead of the hardcoded values.

const style = getComputedStyle(this);
const theme = {
  boardBg: style.getPropertyValue('--dartboard-board-bg') || '#000',
  sector1: style.getPropertyValue('--dartboard-sector-bg-1') || '#111',
  sector2: style.getPropertyValue('--dartboard-sector-bg-2') || '#ffe',
  sector3: style.getPropertyValue('--dartboard-sector-bg-3') || '#b33',
  sector4: style.getPropertyValue('--dartboard-sector-bg-4') || '#252',
  wireColor: style.getPropertyValue('--dartboard-wire-color') || '#fff',
  showWire: style.getPropertyValue('--dartboard-wire-shadow-show') || '1',
};

render(this.#board, theme, context);
Enter fullscreen mode Exit fullscreen mode

Dartboard drawn with alternate colors green color scheme
Our dartboard with an alternate theme

Create as many design tokens as needed to control any aspect of the rendering. Check out the Storybook page to try out different styles.

Six dartboards in a grid each with a different color theme
A customizable dartboard Web Component

What we learned

In this article we explored the process of creating a visually rich vanilla Web Component which uses the HTML Canvas. We set up a production ready project with the Open Web Components toolkit and used the Canvas 2D API for high-performance rendering with responsive resizing and customizable theming.

This is a great start but there’s a lot more we can do with Web Components and the HTML Canvas. In future articles, we’ll cover additional topics such as implementing custom attributes, enhancing user interactivity, achieving high-performance rendering, improving accessibility, and importing our component into popular web frameworks.

Conclusion

Web Components provide a powerful way to create encapsulated and reusable UI elements and combining them with the Canvas API unlocks opportunities for building visually rich and interactive applications. If you’re ready to dive deeper, check out the project’s Storybook and GitHub repository for live demos, source code, and additional customization options. Experiment with these concepts in your own projects and discover how Web Components can simplify and enrich your development process.

Top comments (3)

Collapse
 
dannyengelman profile image
Danny Engelman

While SVG is better suited for many graphics use-cases, the Canvas is optimal for high-performance rendering of animations or interactive elements.

But the dartboard doesn't change after it is drawn... I can see darts fly, but not the board.
So SVG (as CSS background) would have been a whole easier?

Collapse
 
dartbot profile image
Stephen Marsh

You're right, practically speaking this example would be better with SVG. I have plans for some follow-ups to show more interactivity and dynamic updates. Any thoughts on features that would showcase the canvas capabilities ?

Collapse
 
dannyengelman profile image
Danny Engelman

Like you wrote, canvas is great for high performant animation.
So it would be cool to actually throw a dart.

Use ChatGPT to help create the code, it helped me create half the code for my 3D roll-dice.github.io/ experiment