Observable WebComponent
I've read about Web Components before, but never tried to use them. I mostly followed the MDN article (linked above) and the examples github repo that is also linked from that page.
For this website, I figured it might be a nice way to inlcude interactive demos. In this post I'll also explore the Intersection Observer API so that demos with animations can be set up to only animate when visible.
I'll start off by showing the component, and then explaining how I wrote it.
The basics
At its simplest level, a webcomponent is a class that extends HTMLElement
and a call to define that element. For example
class MyComponent extends HTMLElement {
constructor() {
super();
}
}
customElements.define("my-component", MyComponent);
in your HTML code, you'd create one of these like
<my-component></my-component>
There are other options, and there are ways to pass in attributes (you can read the MDN article for more), but here's where we're starting.
Adding a canvas
Most of my projects involve a canvas of some sort, and an animation loop.
Extending upon the previous example, we can implement the connectedCallback
method to add sub elements to our element. You can use this.innerHTML="<canvas></canvas>"
but I'm doing it this way since I want to have a reference to the canvas element anyway.
const CANVAS_WIDTH = 400;
const CANVAS_HEIGHT = 400;
class MyComponent extends HTMLElement {
private ctx: CanvasRenderingContext2D; // this is added here too
constructor() {
super();
}
connectedCallback() {
// Populate this element
const canvas = document.createElement("canvas") as HTMLCanvasElement;
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
this.ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
this.appendChild(canvas);
}
}
Now we have a canvas, let's draw something
Drawing on the canvas
Create a new method draw
and add a private property frames
private frames = 0;
private draw(time: number) {
this.ctx.font = "bold 18px sans-serif";
this.ctx.fillStyle = `hsl(${this.frames / 10},100%,50%)`;
this.ctx.fillRect(0, 0, 400, 400);
this.ctx.fillStyle = `hsl(${(this.frames / 10) + 180},100%,25%)`;
this.ctx.fillText("Frames: " + (this.frames++), 10, 20);
this.ctx.fillText("Time: " + Math.floor(time / 1000), 10, 40);
window.requestAnimationFrame(time => this.draw(time));
}
At the end of connectedCallback
add the following to get the render loop started
window.requestAnimationFrame(time => this.draw(time));
the draw
method will get called at whatever rate the browser feels like. Mobile devices (particularly on low power) may render at a low framerate like 15 or 30fps, and a typical PC may render at 60fps. Don't count on these numbers, and they won't be 100% stable either. It's best practise to use the time
parameter that is populated. It's an arbitrary time (its not Date.now()), but if you just want to compare this frame time with the last frame time, you can do so to get the frame times.
Drawing only when you can see the canvas
We'll use the Intersection Observer API. In the MDN documentation they list the reasons you may want to use it
Intersection information is needed for many reasons, such as:
- Lazy-loading of images or other content as a page is scrolled.
- Implementing "infinite scrolling" websites, where more and more content is loaded and rendered as you scroll, so that the user doesn't have to flip through pages.
- Reporting of visibility of advertisements in order to calculate ad revenues.
- Deciding whether or not to perform tasks or animation processes based on whether or not the user will see the result.
Within the connectedCallback
method, I added the following to create the observer, and hook it up to the canvas within my webcomponent.
// Create an observer
let options: IntersectionObserverInit = {
rootMargin: "0px",
threshold: 0,
};
var observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.intersectionRatio > 0 && this.animRequestId == null) {
this.requestAnim();
} else if (entry.intersectionRatio <= 0) {
this.stopAnim();
}
});
}, options);
observer.observe(canvas);
I've replaced the method to request an animation frame, and added a way to stop it, as well as tracking the animation request id as animRequestId
private animRequestId: number | null = null;
private requestAnim() {
this.animRequestId = window.requestAnimationFrame(time => this.draw(time));
}
private stopAnim() {
if (this.animRequestId != null) {
window.cancelAnimationFrame(this.animRequestId);
this.animRequestId = null;
}
}
All the code, all together
const CANVAS_WIDTH = 400;
const CANVAS_HEIGHT = 400;
class MyComponent extends HTMLElement {
private ctx: CanvasRenderingContext2D;
private animRequestId: number | null = null;
private frames = 0;
constructor() {
super();
}
connectedCallback() {
// Populate this element
const canvas = document.createElement("canvas") as HTMLCanvasElement;
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
this.ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
this.appendChild(canvas);
// Create an observer
let options: IntersectionObserverInit = {
rootMargin: "0px",
threshold: 0,
};
var observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.intersectionRatio > 0 && this.animRequestId == null) {
this.requestAnim();
} else if (entry.intersectionRatio <= 0) {
this.stopAnim();
}
});
}, options);
observer.observe(canvas);
this.requestAnim();
}
private requestAnim() {
this.animRequestId = window.requestAnimationFrame(time => this.draw(time));
}
private stopAnim() {
if (this.animRequestId != null) {
window.cancelAnimationFrame(this.animRequestId);
this.animRequestId = null;
}
}
private draw(time: number) {
this.ctx.font = "bold 18px sans-serif";
this.ctx.fillStyle = `hsl(${this.frames / 10},100%,50%)`;
this.ctx.fillRect(0, 0, 400, 400);
this.ctx.fillStyle = `hsl(${(this.frames / 10) + 180},100%,25%)`;
this.ctx.fillText("Frames: " + (this.frames++), 10, 20);
this.ctx.fillText("Time: " + Math.floor(time / 1000), 10, 40);
this.requestAnim();
}
}
customElements.define("my-component", MyComponent);
One last demo
Here it is again. You'll notice that the frame number will be different, since this has been at the bottom of the page, and not visible