A bar chart is a visual representation of a categorical data set where a bar is a direct mapping of a category and whose size (the height for vertical bars) is proportional to the values they represent.
If one axis has a linear scale (to match the size of the bars), the position of the bars against the other axis (the categories) usually does not matter much, and they simply take the space evenly.
In this article, we’ll cover how to build a bar chart library using web components.
To first calculate the proportions of a bar, we need a simple function to project a value against a segment of one unit representing the domain of possible values we want to display:
const createScale = ({domainMin, domainMax}) => (value) => (value - domainMin) / (domainMax - domainMin);
For instance, if a segment of one unit goes from 0 to 100, the value 50 will be right at the middle of the segment, whereas 25 will be at the quarter.
const scale = createScale({domainMin: 0, domainMax: 100}); scale(50) // > 0.5 scale(25) // > 0.25
What you want the unit of the segment to physically be is then up to you (900px, 4cm, etc). We also need to take care of the values out of the range defined by the domain (i.e., the values you cannot fit on the segment).
Usually, if the value is higher, it is topped at the end of the segment, whereas if it is lower, the relative proportion will be simply null.
// an utility to compose functions together const compose = (...fns) => (arg) => fns.reduceRight((acc, cfn) => cfn(acc), arg); const greaterOrEqual = (min) => (value) => Math.max(min, value); const lowerOrEqual = (max) => (value) => Math.min(max, value); const createProjection = ({domainMin, domainMax}) => compose( lowerOrEqual(1), greaterOrEqual(0), createScale({ domainMin, domainMax }) ); // example const project = createProjection({domainMin: 0, domainMax: 100}); project(50); // > 0.5 "unit" project(120); // > 1 "unit" project(-40); // > 0 "unit
Web components is a set of three technologies that provides the developer the ability to create shareable UI controls as regular DOM elements:
<template>
and <slot>
) helps with the design of the subtree and with how it fits within other DOM treesYou do not have to use all of them together in order to create a web component. People often confuse web components with shadow DOM, but you can create a custom element with no shadow DOM at all.
The power of Custom Elements lies in the fact they are valid HTML elements you can use in a declarative way either through HTML or programmatically with the same API as any HTML element (attributes, events, selectors, etc.).
To create a Custom Element, you need a class that extends the HTML element base class. You then have access to some lifecycles and hook methods:
export class Bar extends HTMLElement { static get observedAttributes() { return ['size']; } get size() { return Number(this.getAttribute('size')); } set size(value) { this.setAttribute('size', value); } // the absolute value mapped to the bar get value() { return Number(this.getAttribute('value')); } set value(val) { this.setAttribute('value', val); } attributeChangedCallback() { this.style.setProperty('--bar-size', `${this.size}%`); } } customElements.define('app-bar', Bar);
Usually, you define the declarative API through HTML attributes (size
, in our case) together with programmatic access through getters and setters. Custom Elements offer some sort of reactive bindings (as you can find in common UI Javascript frameworks) by exposing observable attributes through the static getter observedAttributes
and the reactive callback attributeChangedCallback
.
In our case, whenever the size
attribute changes we update the component style property --bar-size
, which is a CSS variable we could use to set the bars proportions.
Ideally, accessors shall reflect on attributes and therefore use simple data types only (strings, numbers, booleans) because you do not know how the consumer will use the component (with attributes, programmatically, etc.).
Finally, you need to register the custom element into a global registry so the browser knows how to handle the new HTML element it finds in the DOM.
You can now drop the app-bar
tag in a HTML document. As any HTML element, you can associate style to it with a CSS stylesheet. In our case, we can, for example, leverage the reactive CSS variable --bar-size
to manage the heights of the bars.
You will find a running example with the following Code Pen or stackblitz (for a more organized sample). Besides the heights of bars, we have added some animations and some enhancements to prove our point. Custom Elements are before all HTML Elements, which makes them very expressive with standard web technologies such CSS and HTML.
In the previous section, we managed to create something close to an actual bar chart, thanks to a simple web component and a stylesheet. However, if some of the style applied is customized, a good deal of it is part of the functional requirements of any bar chart:
Therefore, we need to encapsulate that part in our component to make its usage less tedious and repetitive for the consumer. Enter the shadow DOM.
Shadow DOM enables the web component to create its own DOM tree isolated from the rest of the document. It means you can set the internal structure without the other elements knowing about it, like a black box.
In the same way, you can define private and scoped style rules specific to the internal parts. Let’s see how it goes with the following example:
import {createProjection} from './util.js'; const template = document.createElement('template'); /// language=css const style = ` :host{ display: grid; width:100%; height: 100%; } :host([hidden]){ display:none; } #bar-area{ align-items: flex-end; display:flex; justify-content: space-around; } ::slotted(app-bar){ flex-grow: 1; height: var(--bar-size, 0%); background: salmon; // default color which can be overwritten by the consumer } `; template.innerHTML = ` <style>${style}</style> <div id="bar-area"> <slot></slot> </div> `; export class BarChart extends HTMLElement { static get observedAttributes() { return ['domainmin', 'domainmax']; } get domainMin() { return this.hasAttribute('domainmin') ? Number(this.getAttribute('domainmin')) : Math.min(...[...this.querySelectorAll('app-bar')].map(b => b.value)); } set domainMin(val) { this.setAttribute('domainmin', val); } get domainMax() { return this.hasAttribute('domainmax') ? Number(this.getAttribute('domainmax')) : Math.max(...[...this.querySelectorAll('app-bar')].map(b => b.value)); } set domainMax(val) { this.setAttribute('domainmax', val); } attributeChangedCallback(...args) { this.update(); } constructor() { super(); this.attachShadow({mode: 'open'}); this.shadowRoot.appendChild(template.content.cloneNode(true)); } update() { const project = createProjection({domainMin: this.domainMin, domainMax: this.domainMax}); const bars = this.querySelectorAll('app-bar'); for (const bar of bars) { bar.size = project(bar.value); } } connectedCallback() { this.shadowRoot.querySelector('slot') .addEventListener('slotchange', () => this.update()); } } customElements.define('app-bar-chart', BarChart);
There are few new things going on here. First, we create a template
element with a DOM tree, which will be used as the private tree of the document thanks to the attached shadow DOM (cf constructor).
Notice that this template has a slot element, which is essentially a hole that the consumer of the component can fill with other HTML elements. In that case, those elements do not belong to the shadow DOM of the web component and remain in the upper scope. Yet they will take their position as defined by the shadow DOM layout.
We also use a new life cycle method, naming connectedCallback
. This function runs whenever the component is mounted into a document. We register an event listener that will ask our component to rerender whenever the slotted content (bars) changes.
We have a scoped style that allows us to implement and encapsulate the functional requirements of the bar chart (what was achieved through a global stylesheet before). The pseudo element :host
refers to the web component root node, whereas ::slotted
allows the component to define some default style on “received” elements (the bars, in our case).
Custom elements have by default the display
property set to inline
; here, we overwrite the default with a grid
. But, because of the CSS specificity rules, we need to handle the case where the component has the hidden
attribute.
In the same way, the calculation of the projected heights is now part of the component internals. Like before, the component has reactive attributes/properties, so whenever the defined domain range changes, the proportions of the bars do, too.
We can now combine our two web components together to create bar charts in HTML. While remaining widely customizable, the consumer no longer has the burden of handling the calculation of the bars’ heights nor their rendering.
You’ll note there is an implicit contract between the two components: the size
attribute of the app-bar
shall be managed by the app-bar-chart
component.
Technically, the consumer could break the behavior interfering with the css variable --bar-size
(leak of encapsulation), but this trade-off gives us a great flexibility at the same time.
<app-bar-chart> <app-bar value="7"></app-bar> <app-bar value="2.5"></app-bar> <app-bar value="3.3"></app-bar> <app-bar value="2.2"></app-bar> <app-bar value="4"></app-bar> <app-bar value="8.3"></app-bar> <app-bar value="3.1"></app-bar> <app-bar value="7.6"></app-bar> <app-bar-chart>
You’ll find in the following codepen (Stackblitz) a more advanced example where you can also define the bars orientations.
So far, the component lets the reader quickly grasp the relative proportions of the categories.
However, without any axis, it is still difficult to map those proportions to absolute values, and to give a label or a category to a given bar.
Categories axis
We stated earlier that the positions of the bars are not very meaningful, and they just need to take the space evenly. The category labels will follow the same logic.
First, we need to change the template of the bar area to add a slot for the axis and add some style to keep the layout consistent. CSS grid
makes it easy:
// bar-chart.js template.innerHTML = ` <style> <!-- ... --> :host{ /* ... */ grid-template-areas: "bar-area" "axis-bottom"; grid-template-rows: 1fr auto; grid-template-columns: auto 1fr; } #bar-area{ /* ... */ grid-area: bar-area; } #axis-bottom{ display: flex; grid-area: axis-bottom; } </style> <div id="bar-area"> <slot name="bar-area"></slot> </div> <div id="axis-bottom"> <slot name="axis-bottom"></slot> </div> `
Now the bar chart has two distinct named slots. We need then to specify which slot the children elements will be inserted in. For the bars, we slot them into the bar-area
section. We add the attribute slot
on the bars with a value bar-area
.
We add this behavior as default into our bar component:
// bar.js export class Bar extends HTMLElement { /* ... */ connectedCallback() { if (!this.hasAttribute('slot')) { this.setAttribute('slot', 'bar-area'); } } }
Within the connectedCallback
, we conditionally add the aforementioned attribute. Note that with default properties, it is often a good practice to give precedence to user-specified attributes (hence the condition) because you don’t know how the consumer will use or extend your component.
Let’s now create a category axis and a label component, which will be a pair of simple logicless components with basic style to enforce the layout:
// label.js const template = document.createElement('template'); /// language=css const style = ` :host{ display:flex; } :host([hidden]){ display:none; } #label-text{ flex-grow: 1; text-align: center; } :host(:last-child) #tick-after{ display: none; } :host(:first-child) #tick-before{ display: none; } `; template.innerHTML = ` <style>${style}</style> <div part="tick" id="tick-before"></div> <div id="label-text"><slot></slot></div> <div part="tick" id="tick-after"></div> `; export class Label extends HTMLElement { constructor() { super(); this.attachShadow({mode: 'open'}); this.shadowRoot.appendChild(template.content.cloneNode(true)); } } customElements.define('app-label', Label); // category-axis.js const template = document.createElement('template'); /// language=css const style = ` :host{ display:flex; border-top: 1px solid gray; } :host([hidden]){ display:none; } ::slotted(app-label){ flex-grow:1; } app-label::part(tick){ width: 1px; height: 5px; background: gray; } `; template.innerHTML = ` <style>${style}</style> <slot></slot> `; export class CategoryAxis extends HTMLElement { constructor() { super(); this.attachShadow({mode: 'open'}); this.shadowRoot.appendChild(template.content.cloneNode(true)); } connectedCallback() { if (!this.hasAttribute('slot')) { this.setAttribute('slot', 'axis-bottom'); } } } customElements.define('app-category-axis', CategoryAxis);
You can now add those components to the HTML document:
<app-bar-chart domainmin="0" domainmax="10"> <app-bar value="2.5"></app-bar> <app-bar value="3.3"></app-bar> <app-bar value="8.3"></app-bar> <app-bar value="3.1"></app-bar> <app-bar value="7.6"></app-bar> <app-category-axis> <app-label> <!-- custom template if you want --> <span>cat-1</span> </app-label> <app-label>cat-2</app-label> <app-label>cat-3</app-label> <app-label>cat-4</app-label> <app-label>cat-5</app-label> </app-category-axis> </app-bar-chart>
There’s nothing new here except for one point: the label template has two elements with the part
attribute. This allows you to customize specific parts of the shadow DOM, whereas they are normally not accessible from outside the component.
You can see it in action in the following code pen (Stackblitz).
Linear scale axis
For the linear axis, we will use mostly a mix of the techniques we have seen so far, but we will introduce a new concept as well: custom events.
As we did earlier for the bar chart component, the linear axis component will expose a declarative API to define the domain range values and the gap between two consecutive ticks.
Indeed, it makes sense to let this component drive the domain range, but at the same time, we don’t want to add a coupling between the bars and the axis.
Instead, we’ll use the parent bar chart component as a mediator between them so that whenever the axis sees a domain change, it will notify the bar chart to re-render the bars.
We can achieve this pattern with custom events:
// linear-axis.js // ... export class LinearAxis extends HTMLElement { static get observedAttributes() { return ['domainmin', 'domainmax', 'gap']; } // ... attributeChangedCallback() { const {domainMin, domainMax, gap} = this; if (domainMin !== void 0 && domainMax !== void 0 && gap) { this.update(); this.dispatchEvent(new CustomEvent('domain', { bubbles: true, composed:true, detail: { domainMax, domainMin, gap } })); } } }
Besides calling for an update, the component emits a CustomEvent, passing the domain values detail. We pass two flags bubbles
and composed
to make sure the event goes up in the tree hierarchy and can go out of the shadow tree boundaries.
Then, in the bar chart component:
// bar-chart.js // ... class BarChar extends HTMLElement { // ... connectedCallback() { this.addEventListener('domain', ev => { const {detail} = ev; const {domainMin, domainMax} = detail; // the setters will trigger the update of the bars this.domainMin = domainMin; this.domainMax = domainMax; ev.stopPropagation(); }); } }
We simply register to the custom event a call to an update on the bars by using the properties setters as before. We have decided to stop the propagation of the event because, in this case, we use the event only to implement the mediator pattern.
As usual, you can have a look at the codepen or the stackblitz if you are interested in the details.
We have now all the basic building blocks to build a bar chart in a declarative way. However, you will not often have the data available at the time you write the code, but rather, loaded dynamically later. This doesn’t really matter — the key is to transform your data into the corresponding DOM tree.
With libraries such React, Vue.js and others, it’s a pretty straightforward progress. Remember that the integration of web components into any web application is trivial as they are, before all, regular HTML Elements.
Another benefit of using web components is the ability to customize the charts and handle plenty of different use cases with a small amount of code.
While chart libraries are usually massive and need to expose plenty of configurations to offer some flexibility, web components allow you to simply use a bit of CSS and Javascript to create your bar chart library.
Thanks for reading!
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
One Reply to "Build a bar chart library with web components"
Very interesting. It made me realize we can already do a lot with the regular “natives” technologies