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
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);
<!DOCTYPE html>
<html lang="en">
<body>
<script type="module" src="dartboard.js">
<my-dartboard></my-dartboard>
</body>
</html>
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
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
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/
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);
}
}
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
]
};
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();
}
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.
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();
};
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.
// 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();
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.
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);
};
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.
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>
`;
...
}
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" }); }
}
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
.
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.
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>
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);
Create as many design tokens as needed to control any aspect of the rendering. Check out the Storybook page to try out different styles.
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)
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?
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 ?
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