Build a Story Web Component with LitElement
Tags: litelement, webcomponents, javascript
Published: 2020-01-14 19:28:40

Stories are a hot UI component these days. Snapchat, Instagram, and Facebook each have social stories for sharing videos and pictures with friends. Google and Apple present information in “story” form in their news apps. In this post we'll build a story component with LitElement, lit-html, and TypeScript.

[LitElement](https://lit-element.polymer-project.org/) is a small base class that makes it easy to build web components. It uses [lit-html](https://lit-html.polymer-project.org/), an efficient HTML template library for JavaScript.

This is what the story component will look like at the end:

{% stackblitz lit-story-viewer view=preview file=index.html %}

# The Essence of Stories

We can think of a social media or news “story” as a collection of cards to be played sequentially, sort of like a slideshow. Actually, stories are literally slideshows. The cards are typically dominated by an image or autoplaying video, and can have additional text on top. Let's build a feature list:

- Cards with an image or video background.
- Swipe left or right to navigate the story.
- Autoplaying videos.
- Ability to add text or otherwise customize cards.

As far as this component’s developer experience, it'd be nice to specify story cards in plain HTML markup, like this:

```html
<story-viewer>
  <story-card>
    <img slot="media" src="some/image.jpg" />
    <h1>Title</h1>
  </story-card>
  <story-card>
    <video slot="media" src="some/video.mp4" loop playsinline></video>
    <h1>Whatever</h1>
    <p>I want!</p>
  </story-card>
</story-viewer>
```

So let's also add that to the feature list.

- Accept a series of cards in HTML markup.

This way anyone can use our story component simply by writing HTML. This is great for programmers and non-programmers alike, and works everywhere HTML does: content management systems, frameworks, etc.

Better get started!

# Setting Up

Let’s get our dependencies: LitElement, lit-html, and Typescript. LitElement and lit-html work great with plain JavaScript too, but I prefer the developer experience of TypeScript.

```
npm i lit-element lit-html
npm i -D typescript
```

For VS Code users, install the lit-plugin extension to get autocompletion, type-checking, and linting of lit-html templates.

While LitElement works in every major browser, we need to polyfill web components for Internet Explorer. The `@webcomponents/webcomponentsjs` package makes it easy, and will only fetch the polyfills if the client’s browser needs them.

```
npm i -D @webcomponents/webcomponentsjs
```

Here's how to include the polyfills in our `index.html`.

```html
<head>
  <script src="node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
</head>
```

# The `<story-card>` Component

When building compound components, I sometimes find it easier to start with the simpler sub-components, and build my way up. So, let's start by building `<story-card>`. It should be able to display a full-bleed video or an image. Users should be able to further customize it with overlay text, for instance.

The first step is to define our component’s class, which extends LitElement. The `customElement` decorator takes care of registering the custom element for us. Now is a good time to make sure you [enable decorators in your tsconfig](https://www.typescriptlang.org/docs/handbook/decorators.html) with the `experimentalDecorators` flag.

```typescript
import { LitElement, customElement } from 'lit-element'
 
@customElement('story-card')
export class StoryCard extends LitElement {
}
```

Now we can use the `<story-card>` custom element, but there's nothing to display yet. To define the element's internal structure, we define the `render` instance method. This is where we'll provide the template for our element, using lit-html's `html` tag.

What should be in this component’s template? We want the user to be able to provide two things: a media element, and anything else they want to overlay. So, we’ll add one `<slot>` for each of those.

Slots are how we specify where to render children of a custom element. For more info, here's a great [walkthrough on using slots](https://developers.google.com/web/fundamentals/web-components/shadowdom#slots).

Separating the media element into its own slot will help us target that element for things like adding full-bleed styling and autoplaying videos. I put the second slot, the one for custom overlays, inside a container element so we can provide some default padding later.

```typescript
import { html } from 'lit-html'
 
export class StoryCard extends LitElement {
  render() {
    return html`
      <div id="media”>
        <slot name="media"></slot>
      </div>
      <div id="content">
        <slot></slot>
      </div>
    `;
  }
}
```

We can now use our `<story-card>` component like this:

```html
<story-card>
  <img slot="media" src="some/image.jpg" />
  <h1>My Title</h1>
  <p>my description</p>
</story-card>
```

But, it looks terrible.

<figure>
  ![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/pkyprjw3l2i9zldbpe4g.png)
  <figcaption>Terrible.</figcaption>
</figure>

Let's add some style. With LitElement, we do that by defining a `static styles` property and returning a template string tagged with `css`. Whatever CSS we write here applies only to our custom element! CSS with shadow DOM is really nice in this way.

Let’s style the slotted media element to cover the `<story-card>`. While we’re here, we can provide some nice formatting for elements in the second slot. That way, users can drop in some `h1`s, `p`s, or whatever, and see something nice by default.

```typescript
import { css } from 'lit-element'
 
export class StoryCard extends LitElement {
  static styles = css`
    #media {
      height: 100%;
    }
    #media ::slotted(*) {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
 
    /* Default styles for content */
    #content {
      position: absolute;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
      padding: 48px;
      font-family: sans-serif;
      color: white;
      font-size: 24px;
    }
    #content > slot::slotted(*) {
      margin: 0;
    }
  `;
}
```

<figure>
  ![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/7ylnpifr09vc72by6q6e.png)
  <figcaption>Lookin' slick.</figcaption>
</figure>

Now we have story cards with background media, and we can put whatever we want on top. Nice! We'll return to StoryCard in a bit to implement autoplaying videos.

# The `<story-viewer>` Component

Our `<story-viewer>` element is the parent of `<story-card>`s. It'll be responsible for laying out the cards horizontally and letting us swipe between them. We'll kick it off the same way we did for `StoryCard`. We want to add story cards as children of the `<story-viewer>` element, so we'll add a slot for those children.

```typescript
import { LitElement, customElement } from 'lit-element';
 
@customElement('story-viewer')
export class StoryViewer extends LitElement {
  render() {
    return html`<slot></slot>`;
  }
}
```

Next up is a horizontal layout. We can approach this by giving all of the slotted `<story-card>`s absolute positioning, and translating them according to their index. We can target the `<story-viewer>` element itself using the `:host` selector.

```typescript
static styles = css`
  :host {
    display: block;
    position: relative;
    /* Default size */
    width: 300px;
    height: 800px;
  }
  ::slotted(*) {
    position: absolute;
    width: 100%;
    height: 100%;
  }`;
```

The user can control the size of our story cards just by externally overriding the default height and width on the host. Like this:

```css
story-viewer {
  width: 400px;
  max-width: 100%;
  height: 80%;
}
```

To keep track of the currently viewed card, let’s add an instance variable `index` to the `StoryViewer` class. Decorating it with LitElement’s `@property` will cause the component to re-render whenever its value changes.

```typescript
import { property } from 'lit-element';
 
export class StoryViewer extends LitElement {
  @property() index: number = 0;
}
```

Each card needs to be translated horizontally into position. Let’s apply these translations in LitElement's [`update` lifecycle method](https://lit-element.polymer-project.org/guide/lifecycle#update). The update method will run whenever a decorated property of this LitElement changes. Usually, we would query for the slot and loop over `slot.assignedElements()`. However, since we only have one unnamed slot, this is the same as using `this.children`. Let's use `this.children`, for convenience.

```typescript
update(changedProperties) {
  const width = this.clientWidth;
  Array.from(this.children).forEach((el: HTMLElement, i) => {
    const x = (i - this.index) * width;
    el.style.transform = `translate3d(${x}px,0,0)`;
  });
  super.update(changedProperties);
}
```

Our `<story-card>`s are now all in a row. It still works with other elements as children, as long as we take care to style them appropriately:

```html
<story-viewer>
  <!-- A regular story-card child... -->
  <story-card>
    <video slot="media" src="some/video.mp4"></video>
    <h1>This video</h1>
    <p>is so cool.</p>
  </story-card>
  <!-- ...and other elements work too! -->
  <img style="object-fit: cover" src="some/img.png" />
</story-viewer>
```


## Progress Bar and Navigation

Next, we’ll add a way to navigate between the cards and a progress bar.

Let’s add some helper functions to `StoryViewer` for navigating the story. They’ll set index for us while clamping it to a valid range:

```typescript
/** Advance to the next story card if possible **/
next() {
  this.index = Math.max(0, Math.min(this.children.length - 1, this.index + 1));
}
 
/** Go back to the previous story card if possible **/
previous() {
  this.index = Math.max(0, Math.min(this.children.length - 1, this.index - 1));
}
```

To expose navigation to the user of the component, we’ll add “previous” and “next” buttons to the `<story-viewer>`. When either button is clicked, we want to call either the `next` or `previous` helper function. lit-html makes it easy to add event listeners to elements; we can render the buttons and add a click listener at the same time like this:

```typescript
export class StoryViewer extends LitElement {
  render() {
    return html`
      <slot></slot>
 
      <svg id="prev" viewBox="0 0 10 10" @click=${e => this.previous()}>
        <path d="M 6 2 L 4 5 L 6 8" stroke="#fff" fill="none" />
      </svg>
      <svg id="next" viewBox="0 0 10 10" @click=${e => this.next()}>
        <path d="M 4 2 L 6 5 L 4 8" stroke="#fff" fill="none" />
      </svg>
    `;
  }
}
```

Check out how we can add event listeners inline on our new svg buttons, right in the `StoryViewer` render method. This works for any event. Just add a binding of the form `@eventname=${handler}` to an element.

Here’s the styling to add to `static styles` for the buttons:

```css
svg {
  position: absolute;
  top: calc(50% - 25px);
  height: 50px;
  cursor: pointer;
}
#next {
  right: 0;
}
```

For the progress bar, we’ll use CSS grid to style little boxes, one for each story card. We can use the `index` property to conditionally add classes to the boxes for indicating whether they’ve been “seen” or not. We could use a conditional expression such as `i <= this.index : ‘watched’: ‘’`, but things could get verbose if we add more classes. Luckily, lit-html vends a directive called classMap to help out. Here’s the progress bar markup added to the bottom of the template in StoryViewer’s `render` method:

```ts
<div id="progress">
  ${Array.from(this.children).map((_, i) => html`
    <div
      class=${classMap({watched: i <= this.index})}
      @click=${_ => this.index = i}
    ></div>`
  )}
</div>
```

I threw in some more click handlers so users can skip straight to a specific story card if they want.

Here are the new styles to add to `static styles`:

```css
::slotted(*) {
  position: absolute;
  width: 100%;
  /* Changed this line! */
  height: calc(100% - 20px);
}
 
#progress {
  position: relative;
  top: calc(100% - 20px);
  height: 20px;
  width: 50%;
  margin: 0 auto;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  grid-gap: 10px;
  align-content: center;
}
#progress > div {
  background: grey;
  height: 4px;
  transition: background 0.3s linear;
  cursor: pointer;
}
#progress > div.watched {
  background: white;
}
```

Navigation and progress bar complete.

<figure>
  ![A cursor navigating back and forth between images of coffee beans and a coffee maker by clicking on left and right arrows](https://thepracticaldev.s3.amazonaws.com/i/c61hty1b2sqnidbp3h4f.gif)
  <figcaption>Look at that sweet, sweet navigation.</figcaption>
</figure>

Now let’s add some flair!

## Swiping

To implement swiping, let's pull in the [Hammer.js](https://hammerjs.github.io/getting-started/) gesture control library. Hammer detects special gestures like pans, and dispatches events with relevant info (like delta X) that we can consume.

```
npm i hammerjs
```

Here's how we can use Hammer to detect pans, and automatically update our element whenever a pan event occurs.

```typescript
import Hammer from 'hammerjs';
 
export class StoryViewer extends LitElement {
  // Data emitted by Hammer.js
  @property() _panData = {};
 
  constructor() {
    super();
    this.index = 0;
    new Hammer(this).on('pan', e => this._panData = e);
  }
}
```

The constructor of a LitElement class is another great place to attach event listeners on the host element itself. The Hammer constructor takes an element to detect gestures on. In our case, it's the `StoryViewer` itself, or `this`. Then, using Hammer's API we tell it to detect the "pan" gesture, and set the pan information onto a new `_panData` property.

By decorating the `_panData` property with `@property`, LitElement will observe changes to `_panData` and perform an update.

So, let's augment the `update` logic to use the pan data:

```typescript
// Update is called whenever an observed property changes.
update(changedProperties) {
  // deltaX is the distance of the current pan gesture.
  // isFinal is whether the pan gesture is ending.
  let { deltaX = 0, isFinal = false } = this._panData
  // When the pan gesture finishes, navigate.
  if (!changedProperties.has("index") && isFinal) {
    deltaX > 0 ? this.previous() : this.next()
  }
  // We don't want any deltaX when releasing a pan.
  deltaX = (isFinal ? 0 : deltaX)
   const width = this.clientWidth
  Array.from(this.children).forEach((el: HTMLElement, i) => {
    // Updated this line to utilize deltaX.
    const x = (i - this.index) * width + deltaX;
    el.style.transform = `translate3d(${x}px,0,0)`
  });
 
  // Don't forget to call super!
  super.update(changedProperties)
}
```

We can now drag our story cards back and forth. To make things smooth, let's go back to `static get styles` and add `transition: transform 0.35s ease-out;` to the `::slotted(*)` selector.

# Autoplay

The last feature we'll add is autoplaying videos. When a story card enters the focus, we want the background video to play, if it exists. When a story card leaves the focus, we should pause its video.

We'll implement this by dispatching ‘entered’ and ‘exited’ custom events on the appropriate children whenever the index changes. In `StoryCard`, we’ll receive those events and play or pause any existing videos. Why choose to dispatch events on the children instead of calling ‘entered’ and ‘exited’ instance methods defined on StoryCard? With methods, the component users would have no choice but to write a custom element if they wanted to write their own story card with custom animations. With events, they can just attach an event listener!

Let’s refactor `StoryViewer`’s `index` property to use a setter, which provides a convenient code path for dispatching the events:

```typescript
class StoryViewer extends LitElement {
  @property() private _index: number = 0
  get index() {
    return this._index
  }
  set index(value: number) {
    this.children[this._index].dispatchEvent(new CustomEvent('exited'));
    this.children[value].dispatchEvent(new CustomEvent('entered'));
    this._index = value
  }
}
```

To finish off the autoplay feature, we'll add event listeners for “entered” and “exited” in the `StoryCard` constructor that play and pause the video.

Remember that the component user may or may not give the `<story-card>` a video element in the media slot. They may not even provide an element in the media slot at all. We have to be careful to not call `play` on an image, or `null`.

```typescript
import { query } from 'lit-element';
 
class StoryCard extends LitElement {
  constructor() {
    super();
 
    this.addEventListener("entered", () => {
      if (this._slottedMedia) {
        this._slottedMedia.currentTime = 0;
        this._slottedMedia.play();
      }
    });
 
    this.addEventListener("exited", () => {
      if (this._slottedMedia) {
        this._slottedMedia.pause();
      }
    });
  }
}
 
 /**
  * The element in the "media" slot, ONLY if it is an
  * HTMLMediaElement, such as <video>.
  */
 private get _slottedMedia(): HTMLMediaElement {
   const el = this._mediaSlot && this._mediaSlot.assignedNodes()[0];
   return el instanceof HTMLMediaElement ? el : null;
 
  /*
   * @query(selector) is shorthand for
   * this.renderRoot.querySelector(selector)
   */
  @query("slot[name=media]")
  private _mediaSlot: HTMLSlotElement;
}
```

Autoplay complete. ✅

# Tip the Scales

Now that we have all of the essential features, let's add one more: a sweet scaling effect. Let's go back one more time to the `update` method of `StoryViewer`. Some math is done to get the value in the `scale` constant. It will equal `1.0` for the active child and `minScale` otherwise, interpolating between these two values as well.

```typescript
update(changedProperties) {
  // ...
  const minScale = 0.8;
  Array.from(this.children).forEach((el: HTMLElement, i) => {
    const x = (i - this.index) * width + deltaX;
 
    // Piecewise scale(deltaX), looks like: __/\__
    const u = deltaX / width + (i - this.index);
    const v = -Math.abs(u * (1 - minScale)) + 1;
    const scale = Math.max(v, minScale);
    // Include the scale transform
    el.style.transform = `translate3d(${x}px,0,0) scale(${scale})`;
  });
  // ...
}
```

That’s all, folks! In this post we covered a lot, including some LitElement and lit-html features, HTML slot elements, and gesture control.

Try forking the StackBlitz below. Have fun!

{% stackblitz lit-story-viewer view=preview file=index.html %}

Discussion on Hacker News: https://news.ycombinator.com/item?id=22049814