Build a Brick Viewer with lit-element

1. Introduction

Web components

Web components are a collection of web standards that allow developers to extend HTML with custom elements. In this codelab, you'll be defining the <brick-viewer> element, which will be able to display brick models!

lit-element

To help us define our custom element <brick-viewer>, we'll use lit-element. lit-element is a lightweight base class that adds some syntactic sugar to the web components standard. This will make it easy for us to get up and running with our custom element.

Get Started

We'll be coding in an online Stackblitz environment, so open this link in a new window:

stackblitz.com/edit/brick-viewer

Let's get started!

2. Define a Custom Element

Class definition

To define a custom element, create a class that extends LitElement and decorate it with @customElement. The argument to @customElement will be the name of the custom element.

In brick-viewer.ts, put:

@customElement('brick-viewer')
export class BrickViewer extends LitElement {
}

Now, the <brick-viewer></brick-viewer> element is ready to use in HTML. But, if you try it, nothing will render. Let's fix that.

Render method

To implement the view of the component, define a method named render. This method should return a template literal tagged with the html function. Put whatever HTML you want in the tagged template literal. This will render when you use <brick-viewer>.

Add the render method:

export class BrickViewer extends LitElement {
  render() {
    return html`<div>Brick viewer</div>`;
  }
}

3. Specifying the LDraw File

Define a property

It would be great if a user of the <brick-viewer> could specify which brick model to display using an attribute, like this:

<brick-viewer src="path/to/model.ldraw"></brick-viewer>

Since we're building an HTML element, we can take advantage of the declarative API and define a source attribute, just like an <img> or <video> tag. With lit-element, it's as easy as decorating a class property with @property. The type option lets you specify how lit-element parses the property for use as an HTML attribute.

Define the src property and attribute:

export class BrickViewer extends LitElement {
  @property({type: String})
  src: string|null = null;
}

<brick-viewer> now has a src attribute that we can set in HTML! Its value is already readable from within our BrickViewer class thanks to lit-element.

Displaying values

We can display the value of the src attribute by using it in the render method's template literal. Interpolate values into template literals using ${value} syntax.

export class BrickViewer extends LitElement {
  render() {
    return html`<div>Brick model: ${this.src}</div>`;
  }
}

Now, we see the value of the src attribute in the <brick-viewer> element in the window. Try this: open your browser's developer tools and manually change the src attribute. Go ahead, try it...

...Did you notice that the text in the element updates automatically? lit-element observes the class properties decorated with @property and re-render the view for you! lit-element does the heavy lifting so you don't have to.

4. Set the Scene with Three.js

Lights, Camera, Render!

Our custom element will use three.js to render our 3D brick models. There are some things we want to do just once for each instance of a <brick-viewer> element, such as set up the three.js scene, camera, and lighting. We'll add these to the constructor the BrickViewer class. We'll keep some objects as class properties so we can use them later: camera, scene, controls, and renderer.

Add in the three.js scene setup:

export class BrickViewer extends LitElement {

  private _camera: THREE.PerspectiveCamera;
  private _scene: THREE.Scene;
  private _controls: OrbitControls;
  private _renderer: THREE.WebGLRenderer;

  constructor() {
    super();

    this._camera = new THREE.PerspectiveCamera(45, this.clientWidth/this.clientHeight, 1, 10000);
    this._camera.position.set(150, 200, 250);

    this._scene = new THREE.Scene();
    this._scene.background = new THREE.Color(0xdeebed);

    const ambientLight = new THREE.AmbientLight(0xdeebed, 0.4);
    this._scene.add( ambientLight );

    const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
    directionalLight.position.set(-1000, 1200, 1500);
    this._scene.add(directionalLight);

    this._renderer = new THREE.WebGLRenderer({antialias: true});
    this._renderer.setPixelRatio(window.devicePixelRatio);
    this._renderer.setSize(this.offsetWidth, this.offsetHeight);

    this._controls = new OrbitControls(this._camera, this._renderer.domElement);
    this._controls.addEventListener("change", () =>
      requestAnimationFrame(this._animate)
    );

    this._animate();

    const resizeObserver = new ResizeObserver(this._onResize);
    resizeObserver.observe(this);
  }

  private _onResize = (entries: ResizeObserverEntry[]) => {
    const { width, height } = entries[0].contentRect;
    this._renderer.setSize(width, height);
    this._camera.aspect = width / height;
    this._camera.updateProjectionMatrix();
    requestAnimationFrame(this._animate);
  };

  private _animate = () => {
    this._renderer.render(this._scene, this._camera);
  };
}

The WebGLRenderer object provides a DOM element that displays the rendered three.js scene. It's accessed via the domElement property. We can interpolate this value into the render template literal, using ${value} syntax.

Remove the src message we had in the template, and insert the renderer's DOM element:

export class BrickViewer extends LitElement {
  render() {
    return html`
      ${this._renderer.domElement}
    `;
  }
}

To allow the renderer's dom element to be shown in its entirety, we also need to set the <brick-viewer> element itself to display: block. We can provide styles in a static property called styles, set to a css template literal.

Add this styling to the class:

export class BrickViewer extends LitElement {
  static styles = css`
    /* The :host selector styles the brick-viewer itself! */
    :host {
      display: block;
    }
  `;
}

Now <brick-viewer> is displaying a rendered three.js scene:

A brick-viewer element displaying a rendered, but empty, scene.

But... it's empty. Let's provide it with a model.

Brick loader

We'll pass the src property we defined earlier to the LDrawLoader, which is shipped with three.js.

LDraw files can separate a Brick model into separate building steps. Total number of steps and individual brick visibility are accessible through the LDrawLoader API.

Copy these properties, the new _loadModel method, and the new line in constructor:

@customElement('brick-viewer')
export class BrickViewer extends LitElement {
  private _loader = new LDrawLoader();
  private _model: any;
  private _numConstructionSteps?: number;
  step?: number;

  constructor() {
    // ...
    // Add this line right before this._animate();
    (this._loader as any).separateObjects = true;
    this._animate();
  }

  private _loadModel() {
    if (this.src === null) {
      return;
    }
    this._loader
        .setPath('')
        // Using our src property!
        .load(this.src, (newModel) => {

          if (this._model !== undefined) {
            this._scene.remove(this._model);
            this._model = undefined;
          }

          this._model = newModel;

          // Convert from LDraw coordinates: rotate 180 degrees around OX
          this._model.rotation.x = Math.PI;
          this._scene.add(this._model);

          this._numConstructionSteps = this._model.userData.numConstructionSteps;
          this.step = this._numConstructionSteps!;

          const bbox = new THREE.Box3().setFromObject(this._model);
          this._controls.target.copy(bbox.getCenter(new THREE.Vector3()));
          this._controls.update();
          this._controls.saveState();
        });
  }
}

When should _loadModel be called? It needs to be invoked every time the src attribute changes. By decorating the src property with @property, we have opted the property into the lit-element update lifecycle. Whenever one of these decorated properties' value changes, a series of methods are called that can access the new and old values of the properties. The lifecycle method we're interested in is called update. The update method takes a PropertyValues argument, which will contain information about any properties that have just changed. This is the perfect place to call _loadModel.

Add the update method:

export class BrickViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    if (changedProperties.has('src')) {
      this._loadModel();
    }
    super.update(changedProperties);
  }
}

Our <brick-viewer> element can now display a brick file, specified with the src attribute.

A brick-viewer element displaying a model of a car.

5. Displaying Partial Models

Now, let's make the current construction step configurable. We'd like to be able to specify <brick-viewer step="5"></brick-viewer>, and we should see what the brick model looks like on the 5th construction step. To do that, let's make the step property an observed property by decorating it with @property.

Decorate the step property:

export class BrickViewer extends LitElement {
  @property({type: Number})
  step?: number;
}

Now, we'll add a helper method that makes only the bricks up to the current build step visible. We'll call the helper in the update method so that it runs every time the step property is changed.

Update the update method, and add the new _updateBricksVisibility method:

export class BrickViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    if (changedProperties.has('src')) {
      this._loadModel();
    }
    if (changedProperties.has('step')) {
      this._updateBricksVisibility();
    }
    super.update(changedProperties);
  }

  private _updateBricksVisibility() {
    this._model && this._model.traverse((c: any) => {
      if (c.isGroup && this.step) {
        c.visible = c.userData.constructionStep <= this.step;
      }
    });
    requestAnimationFrame(this._animate);
  }
}

Okay, now open up your browser's devtools, and inspect the <brick-viewer> element. Add a step attribute to it, like this:

HTML code of a brick-viewer element, with a step attribute set to 10.

Watch what happens to the rendered model! We can use the step attribute to control how much of the model is shown. Here's what it should look like when the step attribute is set to "10":

A brick model with only ten construction steps built.

6. Brick Set Navigation

mwc-icon-button

The end-user of our <brick-viewer> should also be able to navigate the build steps via UI. Let's add buttons for going to the next step, previous step, and first step. We'll use Material Design's button web component to make it easy. Since @material/mwc-icon-button is already imported, we're ready to drop in <mwc-icon-button></mwc-icon-button>. We can specify the icon we'd like to use with the icon attribute, like this: <mwc-icon-button icon="thumb_up"></mwc-icon-button>. All possible icons can be found here: material.io/resources/icons.

Let's add some icon buttons to the render method:

export class BrickViewer extends LitElement {
  render() {
    return html`
      ${this._renderer.domElement}
      <div id="controls">
        <mwc-icon-button icon="replay"></mwc-icon-button>
        <mwc-icon-button icon="navigate_before"></mwc-icon-button>
        <mwc-icon-button icon="navigate_next"></mwc-icon-button>
      </div>
    `;
  }
}

Using Material Design on our page is that easy, thanks to web components!

Event bindings

These buttons should actually do something. The "reply" button should reset the construction step to 1. The "navigate_before" button should decrement the construction step, and the "navigate_next" button should increment it. lit-element makes it easy to add this functionality, with event bindings. In your html template literal, use the syntax @eventname=${eventHandler} as an element attribute. eventHandler will now run when an eventname event is detected on that element! As an example, let's add click event handlers to our three icon buttons:

export class BrickViewer extends LitElement {
  private _restart() {
    this.step! = 1;
  }

  private _stepBack() {
    this.step! -= 1;
  }

  private _stepForward() {
    this.step! += 1;
  }

  render() {
    return html`
      ${this._renderer.domElement}
      <div id="controls">
        <mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
        <mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
        <mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
      </div>
    `;
  }
}

Try clicking the buttons now. Nice job!

Styles

The buttons work, but they don't look good. They're all huddled at the bottom. Let's style them to overlay them on the scene.

To apply styles to these buttons, we return to the static styles property. These styles are scoped, which means they'll only apply to elements within this web component. That's one of the joys of writing web components: selectors can be simpler, and CSS will be easier to read and write. Bye-bye, BEM!

Update the styles so they look like this:

export class BrickViewer extends LitElement {
  static styles = css`
    :host {
      display: block;
      position: relative;
    }
    #controls {
      position: absolute;
      bottom: 0;
      width: 100%;
      display: flex;
    }
  `;
}

A brick-viewer element with restart, backward, and forward buttons.

Reset camera button

End-users of our <brick-viewer> can rotate the scene using mouse controls. While we're adding buttons, let's add one for resetting the camera to its default position. Another <mwc-icon-button> with a click event binding will get the job done.

export class BrickViewer extends LitElement {
  private _resetCamera() {
    this._controls.reset();
  }

  render() {
    return html`
      <div id="controls">
        <!-- ... -->
        <!-- Add this button: -->
        <mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
      </div>
    `;
  }
}

Quicker navigation

Some brick sets have lots of steps. A user may want to skip to a specific step. Adding a slider with step numbers can help with quick navigation. We'll use the <mwc-slider> element for this.

mwc-slider

The slider element needs a few pieces of important data, like the minimum and maximum slider value. The minimum slider value can always be "1". The maximum slider value should be this._numConstructionSteps, if the model has loaded. We can tell <mwc-slider> these values through its attributes. We can also use the ifDefined lit-html directive to avoid setting the max attribute if the _numConstructionSteps property hasn't been defined.

Add an <mwc-slider> between the "back" and "forward" buttons:

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ... backwards button -->
        <!-- Add this slider: -->
        <mwc-slider
            step="1"
            pin
            markers
            min="1"
            max=${ifDefined(this._numConstructionSteps)}
        ></mwc-slider>
        <!-- ... forwards button -->
      </div>
    `;
  }
}

Data "up"

When a user moves the slider, the current construction step should change, and the model's visibility should be updated accordingly. The slider element will emit an input event whenever the slider is dragged. Add an event binding on the slider itself to catch this event and change the construction step.

Add the event binding:

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ...  -->
        <!-- Add the @input event binding: -->
        <mwc-slider
            ...
            @input=${(e: CustomEvent) => this.step = e.detail.value}
        ></mwc-slider>
        <!-- ... -->
      </div>
    `;
  }
}

Woo! We can use the slider to change which step is displayed.

Data "down"

There's one more thing. When the "back" and "next" buttons are used to change the step, the slider handle needs to be updated. Bind <mwc-slider>'s value attribute to this.step.

Add the value binding:

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ...  -->
        <!-- Add the value property binding: -->
        <mwc-slider
            ...
            value=${ifDefined(this.step)}
        ></mwc-slider>
        <!-- ... -->
      </div>
    `;
  }
}

We're almost done with the slider. Add a flex style to make it play nicely with the other controls:

export class BrickViewer extends LitElement {
  static styles = css`
    /* ... */
    mwc-slider {
      flex-grow: 1;
    }
  `;
}

Also, we need to call layout on the slider element itself. We'll do that in the firstUpdated lifecycle method, which is called once the DOM is first laid out. The query decorator can help us get a reference to the slider element in the template.

export class BrickViewer extends LitElement {
  @query('mwc-slider')
  slider!: Slider|null;

  async firstUpdated() {
    if (this.slider) {
      await this.slider.updateComplete
      this.slider.layout();
    }
  }
}

Here's all of the slider additions put together (with extra pin and markers attributes on the slider to make it look cool):

export class BrickViewer extends LitElement {
 @query('mwc-slider')
 slider!: Slider|null;

 static styles = css`
   /* ... */
   mwc-slider {
     flex-grow: 1;
   }
 `;

 async firstUpdated() {
   if (this.slider) {
     await this.slider.updateComplete
     this.slider.layout();
   }
 }

 render() {
   return html`
     ${this._renderer.domElement}
     <div id="controls">
       <mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
       <mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
       <mwc-slider
         step="1"
         pin
         markers
         min="1"
         max=${ifDefined(this._numConstructionSteps)}
         ?disabled=${this._numConstructionSteps === undefined}
         value=${ifDefined(this.step)}
         @input=${(e: CustomEvent) => this.constructionStep = e.detail.value}
       ></mwc-slider>
       <mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
       <mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
     </div>
   `;
 }
}

Here's the final product!

Navigating a car brick model with the brick-viewer element

7. Conclusion

We learned a lot about how to use lit-element to build our very own HTML element. We learned how to:

  • Define a custom element
  • Declare an attribute API
  • Render a view for a custom element
  • Encapsulate styles
  • Use events and properties to pass data

If you want to learn more about lit-element, you can read more at its official site.

You can view a completed brick-viewer element at stackblitz.com/edit/brick-viewer-complete.

brick-viewer is also shipped on NPM, and you can view the source here: Github repo.