Reusable Canvas Based Web Components

Very often the capabilities of the web platform are under utilized. It is easy to revert to libraries built upon the technology that you are already familiar within instead of finding out if the underlying platform has the capability itself. In the years that have already passed commerce, authentication, caching, geolocation, device sensory information, offline capabilities and many more API’s have become available. These technologies are well used by native app developers and are becoming ever more available on the web itself. One such under utilized capability of the web platform is HTML Canvas.

Canvas is an element available in HTML that comes with an API for creating graphics and animations. JavaScript interacts with the API for drawing to the screen and can be used with requestAnimationFrame for updating the canvas on each repaint of the web page. There are countless ways that one could go about encapsulating the sometimes complex implementation of a canvas animation. One such method is a web component. What follows are the patterns that I have followed for implementing reusable canvas based web components. While it may seem like overkill for simple animations it provides a basis for creating more complex and reusable animations such as this flex icon and this exploding image.

Setting the scene

While the web component will distribute the work of animation and drawing there are a few things that it is responsible for.

Creating the Canvas

The web component should be responsible for creating the canvas. This is as simple as attaching shadow DOM to the element and creating a canvas within it. We also create scoped styling within our shadow DOM in order for the component to be displayed as a block level element. This will allow us in the next step to size our component and the animation within it based upon the size the client code sets for it. The other common scenario is a full screen animation where the height and width are set based on the windows width and height.

class ExampleCanvasComponent extends HTMLElement {
  constructor() {
    super();
    this.shadow = this.attachShadow({mode: 'open'});
    this.shadow.innerHTML = `
      <style media="screen">
        :host {
          cursor: pointer;
          display: block;
        }
      </style>
      <canvas></canvas>
    `;
  }
}
Running the Animation Loop

Here we are simply setting the size of the canvas and running the event loop. In each animation frame we clear the canvas and then redraw the animation. Notice that we have an empty array of objects each of which we are expecting to have an update method and a draw method. Later we will add objects to this array and provide these objects with an implementation.

class ExampleCanvasComponent extends HTMLElement {
  ...

  connectedCallback() {
    this.canvas = this.shadow.querySelector('canvas');
    this.context = this.canvas.getContext('2d');

    // Update the canvas to fill the space of the component.
    this.canvas.width = this.clientWidth;
    this.canvas.height = this.clientHeight;
    this.objects = [];

    // Run the animation loop.
    let animate = () => {
      requestAnimationFrame(animate);
      this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
      this.objects.forEach(object => {
        object.update(this.mousePosition);
        object.draw();
      });
    }
    animate();
  }
}
Setting Defaults and Transforming Configurable Values

Configurations passed through the attributes of the element should be scaled to a value of 1. For example providing a speed of 2 should double the speed. In this way the client code does not need to understand the units of measurement  but only that 1 is the scaled “anchor point”.

class ExampleCanvasComponent extends HTMLElement {
  ...

  connectedCallback() {
    ...

    // These defaults also modify the client provided values
    // so that providing "1" acts as the anchor value.
    // Values above 1 will be more than the default and
    // values below 1 will be less than the default.
    this._defaultSpeed = 0.1;
    this._defaultLineThickness = 2;

    // Provide configuration options through attributes on the element.
    this.lineThickness = (parseFloat(this.getAttribute('line-thickness'))
        * this._defaultLineThickness)
        || this._defaultLineThickness;
    this.speed = (parseFloat(this.getAttribute('speed'))
        * this._defaultSpeed)
        || this._defaultSpeed;

    ...
  }
}
Maintaining Environment Information

The web component is also responsible for maintaining environment information. This will be things such as the position of the mouse, user input, or any other state information that is global to the entire scene. This information can then be shared with each object within the scene.

class ExampleCanvasComponent extends HTMLElement {
  ...

  connectedCallback() {
    ...

    // Initialize the environment information.
    this.mousePosition = {
      x: 0,
      y: 0
    };

    // The component is resopobsible for maintaining environment information.
    this.canvas.addEventListener('mousemove', event => {
      let rect = this.canvas.getBoundingClientRect();
      this.mousePosition = {
        x: event.clientX - rect.left,
        y: event.clientY - rect.top
      };
    }, false);

    this.canvas.addEventListener("mouseenter", event => this.hovering = true);
    this.canvas.addEventListener("mouseleave", event => this.hovering = false);

    ...
  }
}

Making Stuff Happen

Now that the scene is set we need to make some stuff happen. The work of updating and drawing the objects within the scene will be distributed to other ES6 classes that each represent a type of object. The web component has one last responsibility and that is to create these self maintaining objects.

class ExampleCanvasComponent extends HTMLElement {
  connectedCallback() {
    ...

    this.objects = []

    // Set the scene by creating one or more objects.
    this.objects.push(new ExampleLine({
      context: this.context,
      contextWidth: this.canvas.width,
      contextHeight: this.canvas.height,
      thickness: this.lineThickness,
      speed: this.speed
    }));

    ...
  }
}

Now that we have created an “ExampleLine” object we should probably actually implement it. Notice that this is not a web component – each scene only has a single web component which acts as the stage in which the objects are shown. The objects that we add to the stage are standard ES6 classes that are each capable of maintaining their own state, updating themselves based upon environmental information, and drawing themselves to the canvas. The first step is to create a constructor which destructures it’s parameter and sets some default values.

class ExampleLine {
  constructor({context, contextWidth, contextHeight,
               color = 'rgb(0,0,0)',
               thickness = 2,
               speed = 2,
               sensitivity = 0.1,
               minOpacity = 0.2}) {
    this.context = context;
    this.contextWidth = contextWidth;
    this.contextHeight = contextHeight;
    this.color = color;
    this.thickness = thickness;
    this.speed = speed;
    this.sensitivity = sensitivity;
    this.minOpacity = minOpacity;
  }

  update(mousePosition) {
    ...
  }

  draw() {
    ...
  }
}

Now in the update method we can use the internal state of the object plus any provided environment information such as the mouse position in order to update the internal state. This could be coordinates, lengths, distances, radius, colors, or anything else that the object needs in order to draw itself. Then in the draw method we only rely on internal state and nothing else.

Do Not Use Absolutes

Now the object that we are going to create on our canvas is going to be a line that wobbles back and forth and updates it’s opacity based upon the mouses position. To do this we first need to initialize some state as shown below. Here we essentially are creating the information we need to update our internal state (the targets array and the target index) and the information we need to draw our object (this.p1 and this.p2).

class ExampleLine {
  constructor(...) {
    ...

    // This object will be a line with endpoints of p1 and p2.
    // It will move between the provided list of targets.
    this.targets = [
      { p1: { x: 0, y: 0 },
        p2: { x: 1, y: 1 } },
      { p1: { x: 0, y: 1 },
        p2: { x: 1, y: 0 } }
    ];

    // The end points of our line will move toward the target
    // indicated by the this target index.
    this.targetIndex = 0;

    // Our starting end points will be the first target listed.
    this.p1 = this.targets[0].p1;
    this.p2 = this.targets[0].p2;
  }
}
The 0 to 1 Scale

Notice that our x and y coordinates are given as scaled values.This is crucially important for making reusable components. The point (1,1) represents the maximum width and maximum height. This is so that client code can drop this object into any sized canvas and the object will be scaled appropriately. The internal coordinates of all of your objects should be on a 0 to 1 scale.

Now it will be easier to do the update and draw if we have some helper methods. These are fairly straightforward. Notice that the pos() method converts from the 0 to 1 scale to the actual pixel width and height of the containing canvas.

class ExampleLine {
  ...

  get pos() {
    // Only convert from scaled positions to absolute
    // positions when they are needed to draw.
    // Internal state should be scaled positions so
    // that the client can resize the component.
    return {
      p1: { x: this.contextWidth  * this.p1.x,
            y: this.contextHeight * this.p1.y },
      p2: { x: this.contextWidth  * this.p2.x,
            y: this.contextHeight * this.p2.y }
    }
  }

  get previousTarget() {
    // In order to implement an easing function we will
    // need to calculate the previous target
    return this.targetIndex === 0
      ? this.targets[this.targets.length - 1]
      : this.targets[this.targetIndex - 1];
  }

  get target() {
    // This is just a helper method for getting the current target.
    return this.targets[this.targetIndex];
  }

  updateTarget() {
    // This is another helper method for easily updating
    // the current target index to the next target.
    if (this.targetIndex === this.targets.length - 1) {
      this.targetIndex = 0;
    } else {
      this.targetIndex++;
    }
  }

  hasReachedTarget() {
    // Move any commonly used algorithms into utility methods.
    return distanceBetween(this.p1, this.target.p1) < this.sensitivity &&
           distanceBetween(this.p2, this.target.p2) < this.sensitivity;
  }
}

At this point we have done all of the legwork to implement both our update method and our draw method. We have all the ingredients necessary, now we just need to put them together in order to have a self maintained, reusable object that can be dropped into any canvas.

class ExampleLine {
  ...

  update(mousePosition = { x: 0, y: 0 }) {
    if (this.hasReachedTarget()) this.updateTarget();

    // Update internal state based upon environment information.
    this.alpha = ((mousePosition.x / this.contextWidth)
                 * (1 - this.minOpacity)) + this.minOpacity;

    // Move algorithms into utility methods.
    this.p1 = easePoint(this.previousTarget.p1, this.p1, this.target.p1, this.speed);
    this.p2 = easePoint(this.previousTarget.p2, this.p2, this.target.p2, this.speed);
  }

  draw() {
    // The draw method should only rely on internal state.
    this.context.beginPath();
    this.context.lineWidth = this.thickness;
    this.context.strokeStyle = this.color;
    this.context.globalAlpha = this.alpha;
    this.context.lineTo(this.pos.p1.x, this.pos.p1.y);
    this.context.lineTo(this.pos.p2.x, this.pos.p2.y);
    this.context.stroke();
  }
}

Notice that the only place we actually interact with the canvas API is in the draw method. In addition this method only relies on internal state and so any object of this class is always capable of being drawn. This update method relies on environment information but not all update methods will. For example a ball bouncing around the canvas would not need environment information, but a ball bouncing off of the other balls in the canvas would need environment information.

The full example of this wobbling line can be seen in this code pen. But do not let it’s simplicity fool you. This same pattern was used to create more complex animations such as this crumbling granite.

Knowing when to quit

It is important that the web component knows when it does not need to run the animation. For example if an animation only happens upon hovering then the animation updates should only happen when the mouse hovers over the element. Here on line 8 we check to see if the mouse is hovering over the element before doing any additional work. This will make your canvas based components performant and prevent them from using up the available resources of the browser and otherwise slowing the responsiveness of the webpage.

class ExampleCanvasComponent extends HTMLElement {
  ...

  connectedCallback() {
    ...

    let animate = () => {
      requestAnimationFrame(animate);
      if (this.hovering) {
        this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
        this.objects.forEach(object => {
          object.update(this.mousePosition);
          object.draw();
        });
      }
    }
    animate();
  }
}

Long Running Animations

Some animations run continuously and never end. If these types of animations create new objects and add them to the scene, then they need to destroy objects and collect their memory at the same rate. In this example we create a new object ever 100ms. After 2 seconds we begin to free up that memory at the same rate. This is the method I employed in the rain animation that I created.

setInterval(() => {
  this.objects.push(new SomeExampleObject({ context: this.context }));
}, 100);

setTimeout(() => {
  setInterval(() => {
    this.objects.shift();
  }, 100);
}, 2000);

Final Thoughts

In creating reusable animated components such as site intros and flexible icons it is important to encapsulate that behavior in a way such that the hard computation can be reused between components and the components themselves are easily reusable. These patterns provide a structure for making reusable canvas based web components.

  1. The web component sets the scene and maintains environment information.
  2. The objects in the scene know how to update and draw themselves.
  3. Coordinates use a 0 to 1 scale.
  4. Components know when to quit the animation.
  5. Objects are not indefinitely created faster than they are destroyed.

Leave a Reply

avatar
  Subscribe  
Notify of