Build a Story Component with lit-element

1. Introduction

Stories are a popular UI component these days. Social and news apps are integrating them into their feeds. In this codelab we'll build a story component with lit-element and TypeScript.

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

A completed story-viewer component displaying three images of coffee

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. Here's a what we'll build:

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>

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

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.

Prerequisites

  • A shell where you can run git and npm
  • A text editor

2. Setting Up

Start by cloning this repo: story-viewer-starter

git clone git@github.com:PolymerLabs/story-viewer-starter.git

The environment is already set up with lit-element and TypeScript. Just install the dependencies:

npm i

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

Start the development environment by running:

npm run dev

You're ready to begin coding!

3. The <story-card> Component

When building compound components, it's sometimes easier to start with the simpler sub-components, and build 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, for instance, with overlay text.

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 (if you're using the starter repo, it's already enabled).

Put the following code into story-card.ts:

import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('story-card')
export class StoryCard extends LitElement {
}

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

What should be in this component's template? The user should be able to provide two things: a media element, and an overlay. So, we'll add one <slot> for each of those.

Slots are how we specify children of a custom element should be rendered. For more info, here's a great walkthrough on using slots.

import { html } from 'lit';

export class StoryCard extends LitElement {
  render() {
    return html`
      <div id="media">
        <slot name="media"></slot>
      </div>
      <div id="content">
        <slot></slot>
      </div>
    `;
  }
}

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

The <story-card> component can now be used like this:

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

But, it looks terrible:

an unstyled story-viewer displaying a picture of coffee

Adding style

Let's add some style. With lit-element, we do that by defining a static styles property and returning a template string tagged with css. Whatever CSS written 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, component users can drop in some <h1>s, <p>s, or whatever, and see something nice by default.

import { css } from 'lit';

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;
    }
  `;
}

a styled story-viewer displaying a picture of coffee

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

4. 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 add a slot for those children.

Put the following code in story-viewer.ts:

import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';

@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.

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:

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.

import { property } from 'lit/decorators.js';

export class StoryViewer extends LitElement {
  @property({type: Number}) index: number = 0;
}

Each card needs to be translated horizontally into position. Let's apply these translations in lit-element's update lifecycle method. The update method will run whenever an observed property of this component 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.

import { PropertyValues } from 'lit';

export class StoryViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    const width = this.clientWidth;
    Array.from(this.children).forEach((el: Element, i) => {
      const x = (i - this.index) * width;
      (el as HTMLElement).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:

<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>

Go to build/index.html and uncomment the rest of the story-card elements. Now, let's make it so we can navigate to them!

5. 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.

In story-viewer.ts, in the StoryViewer class, add:

/** 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 end-user, 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.

Update the render method to the following:

export class StoryViewer extends LitElement {
  render() {
    return html`
      <slot></slot>

      <svg id="prev" viewBox="0 0 10 10" @click=${() => 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=${() => 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 render method. This works for any event. Just add a binding of the form @eventname=${handler} to an element.

Add the following to the static styles property to style the buttons:

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. First, import classMap:

import { classMap } from 'lit/directives/class-map';

And add the following markup to the bottom of the 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>

We also 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;
}

Navigation and progress bar complete. Now let's add some flair!

6. Swiping

To implement swiping, we'll utilize 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.

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

import { state } from 'lit/decorators.js';
import 'hammerjs';

export class StoryViewer extends LitElement {
  // Data emitted by Hammer.js
  @state() _panData: {isFinal?: boolean, deltaX?: number} = {};

  constructor() {
    super();
    this.index = 0;
    new Hammer(this).on('pan', (e: HammerInput) => 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 @state, LitElement will observe changes to _panData and perform an update, but there won't be an associated HTML attribute for the property.

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

// Update is called whenever an observed property changes.
update(changedProperties: PropertyValues) {
  // 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: Element, i) => {
    // Updated this line to utilize deltaX.
    const x = (i - this.index) * width + deltaX;
    (el as HTMLElement).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:

::slotted(*) {
  ...
  transition: transform 0.35s ease-out;
}

Now we have smooth swiping:

Navigating between story-cards with smooth swiping

7. 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 {
  @state() 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 img, or on null.

Back in story-card.ts, add the following:

import { query } from 'lit/decorators.js';

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|null {
   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. ✅

8. 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.

Change the loop in the update method in story-viewer.ts to be:

update(changedProperties: PropertyValues) {
  // ...
  const minScale = 0.8;
  Array.from(this.children).forEach((el: Element, 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 as HTMLElement).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.

For a completed version of this component, visit: https://github.com/PolymerLabs/story-viewer.