DEV Community

loading...
Cover image for Build a Story Web Component with LitElement

Build a Story Web Component with LitElement

straversi profile image Steven Traversi ・11 min read

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 is a small base class that makes it easy to build web components. It uses lit-html, an efficient HTML template library for JavaScript.

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

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

<head>
  <script src="node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
</head>
Enter fullscreen mode Exit fullscreen mode

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 with the experimentalDecorators flag.

import { LitElement, customElement } from 'lit-element'

@customElement('story-card')
export class StoryCard extends LitElement {
}
Enter fullscreen mode Exit fullscreen mode

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.

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.

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>
    `;
  }
}
Enter fullscreen mode Exit fullscreen mode

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

<story-card>
  <img slot="media" src="some/image.jpg" />
  <h1>My Title</h1>
  <p>my description</p>
</story-card>
Enter fullscreen mode Exit fullscreen mode

But, it looks terrible.

Alt Text


Terrible.

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 h1s, ps, or whatever, and see something nice by default.

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;
    }
  `;
}
Enter fullscreen mode Exit fullscreen mode

Alt Text


Lookin' slick.

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.

import { LitElement, customElement } from 'lit-element';

@customElement('story-viewer')
export class StoryViewer extends LitElement {
  render() {
    return html`<slot></slot>`;
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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

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

story-viewer {
  width: 400px;
  max-width: 100%;
  height: 80%;
}
Enter fullscreen mode Exit fullscreen mode

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.

import { property } from 'lit-element';

export class StoryViewer extends LitElement {
  @property() index: number = 0;
}
Enter fullscreen mode Exit fullscreen mode

Each card needs to be translated horizontally into position. Let’s apply these translations in LitElement's update lifecycle method. 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.

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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:

/** 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));
}
Enter fullscreen mode Exit fullscreen mode

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:

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>
    `;
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

svg {
  position: absolute;
  top: calc(50% - 25px);
  height: 50px;
  cursor: pointer;
}
#next {
  right: 0;
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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:

::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;
}
Enter fullscreen mode Exit fullscreen mode

Navigation and progress bar complete.

A cursor navigating back and forth between images of coffee beans and a coffee maker by clicking on left and right arrows


Look at that sweet, sweet navigation.

Now let’s add some flair!

Swiping

To implement swiping, let's pull in the Hammer.js 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
Enter fullscreen mode Exit fullscreen mode

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

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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)
}
Enter fullscreen mode Exit fullscreen mode

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:

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

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})`;
  });
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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!

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

Discussion (3)

pic
Editor guide
Collapse
madeinlagny profile image
madeInLagny

Hi Steven, great piece of work. I have tried to convert your code to plain Javascript. When I import HammerJs the way you do, I get a 'SyntaxError: The requested module '../../node_modules/hammerjs/hammer.js' does not provide an export named 'default'.
Any idea ?.
Cheers !

Collapse
straversi profile image
Steven Traversi Author

I would try simply import 'hammerjs'.

Collapse
bennypowers profile image
Benny Powers 🇮🇱🇨🇦

No scroll-snap? Denied!

No Stairway? Denied!

Seriously though, awesome post. What a nice way to celebrate the utter downfall of IE11 :D