Web Components are a new suite of technologies that enable you to write reusable elements on a HTML page. These elements can have a custom name, and can encapsulate any functionality you can imagine.

What are Web Components?

Web Components:

What you will build

This codelab will walk you through creating two Web Components, using Custom Elements and Shadow DOM. These elements will be named codelab-dragdrop and codelab-effects. You'll combine them together to create a website that can manipulate images that are dragged onto the page.

What you'll learn

This is a quick codelab! Don't worry if we're glossing over some topics—we want you to build something fast and pick up some knowledge along the way.

What you'll need

Download the Code

If you'd like to be able to skip forward and see the code for each step (in the step1, step2, etc folders), click the following link to download all the code for this codelab:

https://github.com/googlecodelabs/image-styling-web-components/archive/master.zip

Basic HTML boilerplate

To start with, let's create a single HTML file. We'll do all our work here. Open your favourite text editor and paste this in:

<!DOCTYPE html>
<html>
<head>
<script>
/* code will go here */
</script>
</head>
<body>

<h1>Image Styling with Web Components</h1>

<!-- elements will go here -->

</body>
</html>

Save this file, and open it with a recent version of Chrome or Safari. You'll see exactly what you'd expect—just a simple heading.

Let's build support for dragging and dropping an image onto this page. We'll create codelab-dragdrop, which will be a drop target for files, wrapping up some of the logic that would normally have to be expressed in imperative JavaScript.

Your First Custom Element

There's two parts to our work: where we define this new element, and where we use (or instantiate) the element. Let's start by pretending it exists, and use it inside our HTML:

<!-- elements will go here -->
<codelab-dragdrop></codelab-dragdrop>

Voila! ... except, this won't do anything. If you reload the page you had open, your browser doesn't know about this element. There's no central registry—you have to define this element yourself (or maybe include a dependency that requires it).

Defining the Element

A Custom Element, at its core, is an ES6 class that extends HTMLElement, which is then passed to customElements.define. Let's create a boilerplate one inside the script tag:

/* code will go here */
class CodelabDragdrop extends HTMLElement {
  constructor() {
    super();
  }

  connectedCallback() {
    // we'll do stuff here later
    console.info('Element connected!');
  }
}
customElements.define('codelab-dragdrop', CodelabDragdrop);

Reload the page again—inside Developer Tools, you'll see the above message—'Element connected!'. Technically, you've now created a custom element!—very technically.

Next Steps

On the next page, we'll extend this element to be a place you can drop files.

Create a target

If you open up Developer Tools and "inspect" your codelab-dragdrop element, you'll see that it has zero size. That's normal—elements don't have implicit size.

For now, let's just place a large element inside codelab-dragdrop, so that it gets implicit size. Change the HTML to something like this:

<!-- elements will go here -->
<codelab-dragdrop>
  <div style="width: 200px; height: 200px; background: red;">
  </div>
</codelab-dragdrop>

Reload the page, and you'll see a big red box. More importantly, inspecting the page will show that the codelab-dragdrop is now 200px by 200px too, as it holds our inner red square.

Add handlers

Let's extend our codelab-dragdrop to allow files to be dragged onto it. For this codelab, we'll just accept files from your computer—so if you don't have a picture handy, we suggest downloading the Paul and Jake photo from before.

Web Components are about encapsulating some functionality. The way you can do that is by adding code inside the element's definition—the ES6 class we wrote before. So let's update its constructor method by adding listeners to the element itself:

  constructor() {
    super();  // you always need super

    this.addEventListener('dragover', (ev) => {
      ev.preventDefault();
    });
    this.addEventListener('drop', (ev) => {
      ev.preventDefault();
      const file = ev.dataTransfer.files[0] || null;
      file && this._gotFile(file);
    });
  }

These will handle two events related to drag and drop. The key one here is the drop handler, which is set up to call _gotFile with the first file dragged (if any). Every instance of codelab-dragdrop will have this in-built behavior—pretty cool!

Emit events

You may have noticed, of course, that we haven't written the _gotFile method. The goal of this codelab is to write an image manipulator, so we want a way to create an image and pass it to the manipulator.

Turns out, one of the best ways to do this, or provide a generic interface, is to use something we have available to us as part of HTML itself. Let's generate an event—just like drop, from the previous step—but one which we've specified, that contains just one valid image generated from the dropped file. (You can imagine doing more work—e.g., maybe you want to resize the image first, then send it).

Add the _gotFile method after the connectedCallback method, in the class—it'll load the File as an Image, and emit a custom "image" event—like this:

  connectedCallback() {
    // we'll do stuff here later
    console.info('Element connected!');
  }
  // add _gotFile below here!

  _gotFile(file) {
    const image = new Image();
    const reader = new FileReader();
    reader.onload = (event) => {
      // when the reader is ready
      image.src = event.target.result;
      image.onload = () => {
        // when the image is ready
        const params = {
          detail: image,
          bubbles: true,
        };
        const ev = new CustomEvent('image', params);
        this.dispatchEvent(ev);
      };
    };
    reader.readAsDataURL(file);
  }

Great! Now if you reload the page and drop an image onto the red square—the page won't load the image... but not much else has changed. We need to listen to this new "image" event.

Trying it out

If you open up the Developer Tools Console and paste this in:

document.querySelector('codelab-dragdrop').addEventListener('image',
    (ev) => console.info('got image', ev.detail));

And then drag a file—grab an image from the internet or your machine—onto the red blob, you'll see a log message describing that file. Woohoo!

Your Second Custom Element

Let's now create a second custom element, codelab-effects. This element will render our image and possibly apply interesting visual effects to it. To start with, this is pretty much the same as the last element—with one extra detail—and can be included alongside it inside the script tag:

class CodelabEffects extends HTMLElement {
  constructor() {
    super();
    this.root = this.attachShadow({mode: 'open'});
  }
}
customElements.define('codelab-effects', CodelabEffects);

Hmm—what's this attachShadow method?

Create a Shadow Root

Shadow DOM, given to use via the attachShadow method, allows us to add custom HTML to an element that isn't really on the page—users won't see it if they use Developer Tools, and it's hidden from normal calls to things like document.querySelector() or getElementById().

Let's demonstrate by adding some code. After the this.root = ... line, let's define the HTML that's inside our Shadow DOM:

    this.root = this.attachShadow({mode: 'open'});
    this.root.innerHTML = `
<style>
:host {
    background: #fff;
    border: 1px solid black;
    display: inline-block;
}
</style>
<canvas id="canvas" width="512" height="512"></canvas>
<table>
  <tr>
    <td>AMOUNT</td>
    <td><input id="amount" type="range" min="3" max="40" value="10"></td>
  </tr>
</table>
`;

Finally, let's put it together and use this element inside our existing codelab-dragdrop element. In doing this, be sure to remove the red box we had before—now, the codelab-effects element will help give us a target:

<!-- elements will go here -->
<codelab-dragdrop>
  <codelab-effects></codelab-effects>
</codelab-dragdrop>

Reload the page and take a look at the result. It should be as you expect—a gray background with an "AMOUNT" slider. And if you use Developer Tools, you'll see the content under a special #shadow-root node, which isn't made available to the HTML page at large. Cool, huh! 👍🎉

Dragging files here won't do anything yet—let's hook that up!

Putting it together

Remember that snippet we pasted into the page as part of the previous step? Let's use something like that. Let's modify our elements and add a short script, so we can hook up their events:

<!-- elements will go here -->
<codelab-dragdrop id="dragdrop">
  <codelab-effects id="effects"></codelab-effects>
</codelab-dragdrop>
<script>
dragdrop.addEventListener('image', (ev) => {
  effects.image = ev.detail;  // set the image that we got in dragdrop
});
</script>

Now when the "image" event fires—the one we made earlier—we set a property on the codelab-effects element. That doesn't do anything just yet, so let's add a property to that element's source code, that grabs the pixel data of the image we just provided:

  constructor() {
    super();
    this.root = this.attachShadow({mode: 'open'});
    // Leave the root.innerHTML part alone
  }

  // Add this method
  set image(image) {
    const canvas = this.root.getElementById('canvas');

    // resize image to something reasonable
    canvas.width = Math.min(1024, Math.max(256, image.width));
    canvas.height = (image.height * (canvas.width / image.width));

    // clone buffer to get one of same size
    const buf = canvas.cloneNode(true);
    const ctx = buf.getContext('2d');
    ctx.drawImage(image, 0, 0, buf.width, buf.height);
    this.data = ctx.getImageData(0, 0, buf.width, buf.height).data;
    console.info(this.data);
  }

Save and reload! Now if you drag a file onto the grey box, you'll see a message inside Developer Tools—containing the raw image data of what you just dragged in.

Basic Styling

This is all well and good, but let's take this data and draw it to our actual canvas. Instead of using console.log, let's now display something.

Disclaimer, the next section is a big chunk of code—it looks at the image data, which we created inside the set image method, and draws it onto our canvas. This codelab isn't specifically about using canvas and working with image data, but it helps demonstrate Web Components, and hopefully you find the effect interesting.

Let's add this code now:

    // Replace console.info with:
    this.draw();
  }

  // And add this method
  draw() {
    const canvas = this.root.getElementById('canvas');
    canvas.width = canvas.width;  // clear canvas
    const context = canvas.getContext('2d');

    const amount = +this.root.getElementById('amount').value;
    const size = amount * .8;

    for (let y = amount; y < canvas.height; y += amount * 2) {
      for (let x = amount; x < canvas.width; x += amount * 2) {
        const index = ((y * canvas.width) + x) * 4;
        const [r,g,b] = this.data.slice(index, index+3);
        const color = `rgb(${r},${g},${b})`;

        context.beginPath();
        context.arc(x, y, size, 0, 360, false);
        context.fillStyle = color;
        context.fill();
      }
    }
  }

Phew. That section was long—but hopefully there's a payoff. Reload the page and drag your favourite image onto the page—you'll see a Pointillism effect.

Click to Download

If you want to share or use the Pointillism-inspired image you've just created, you'll have to right click and download the image. Let's streamline this experience by adding a link that automatically downloads the image (just after our canvas), first by adding a link below the canvas:

    this.root.innerHTML = `
...
<canvas id="canvas" width="512" height="512"></canvas>
<br /><a href="#" id="link">Download</a>
...
`;

Then add a handler right below the innerHTML definition:

...
</table>
`;
    // And add this handler
    const link = this.root.getElementById('link');
    link.addEventListener('click', (ev) => {
      link.href = this.root.getElementById('canvas').toDataURL();
      link.download = 'pointify.png';
    });

Great! Save and reload the page—drag an image in and click "Download". You'll see a file download onto your computer.

That was an easy step—next we'll add some controls to make the generated image more interesting!

Making it respond

You might have noticed that we have an "AMOUNT" slider. If you were really keen, you'd notice that we actually use its value to control the level of Points we draw. But it only happens once, when the image itself is dropped—what if moving the slider around could redraw the image?

Let's do that—inside the constructor of CodelabEffects, let's add a listener on this.root, which is where all our Shadow DOM (including the "AMOUNT" slider) lives. This will respond to any changes generated by elements inside our Shadow DOM and call our draw method.

...
      link.download = 'pointify.png';
    });

    //add these two new listeners
    this.root.addEventListener('input', (ev) => this.draw());
    this.root.addEventListener('change', (ev) => this.draw());

  }

Now, moving the slider will change the Point-ness of the last dragged image. Your sample should look something like:

Advanced controls

Now let's have some real fun with this - we can add additional controls in our Shadow DOM.

    this.root.innerHTML = `
... <!-- add some new <tr>'s at the bottom -->
  <tr>
    <td>SIZE</td>
    <td><input id="size" type="range" min="0" max="4" step="0.01" value="1"></td>
  </tr>
  <tr>
    <td>OPACITY</td>
    <td><input id="opacity" type="range" min="0" max="1" step="0.01" value="1"></td>
  </tr>
  <tr>
    <td>ATTENUATION</td>
    <td><input id="attenuation" type="checkbox"></td>
  </tr>

</table>
`;

And let's update our rendering code inside draw:

 draw() {
    const canvas = this.root.getElementById('canvas');
    canvas.width = canvas.width;  // clear canvas
    const context = canvas.getContext('2d');

    const attenuation = this.root.getElementById('attenuation').checked;
    const amount = +this.root.getElementById('amount').value;
    const size = this.root.getElementById('size').value * amount;
    const opacity = this.root.getElementById('opacity').value;

    for (let y = amount; y < canvas.height; y += amount * 2) {
      for (let x = amount; x < canvas.width; x += amount * 2) {
        const index = ((y * canvas.width) + x) * 4;
        const [r,g,b] = this.data.slice(index, index+3);
        const color = `rgba(${r},${g},${b},${opacity})`;

        const weight = 1 - ( this.data[ index ] / 255 );
        const radius = (attenuation ? size * weight : size);

        context.beginPath();
        context.arc(x, y, radius, 0, 360, false);
        context.fillStyle = color;
        context.fill();
      }
    }
  }

Size controls the base radius of your circles, opacity controls the alpha channel, and attenuation will re-size the circles based on how dark each one is.

Extra credit

What other features can you think of to add? Maybe color filters to tint the whole image? Using different shapes? Less-than-perfect-grid placement? There's lots of possibilities with this Component!

First—congratulations, you've made it through the codelab! You've created two Web Components: an image manipulator and a drag and drop element. When combined, they make a convenient and interesting application.

Now, show off your work!

Share your pointilized images on Twitter using #codelabs, and they might show up as part of the 2017 Chrome Dev Summit! You can also include the short URL to this lab to let others try it out: goo.gl/dCVXqG

A note on Web Components in 2017

The caveat to components: in 2017, Web Components are supported well by both Safari and Chrome (plus Chromium-based browsers), with other browsers—e.g., Edge, Firefox—coming soon. Consider that you might need to use a polyfill depending on your audience.

There's also a variety of Web Component libraries available, such as Polymer, which provide a higher-level layer over the very low-level components we've written here today.