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>
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
.
<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 with the experimentalDecorators
flag.
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.
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>
`;
}
}
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>
But, it looks 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 h1
s, p
s, 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;
}
`;
}
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>`;
}
}
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-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. 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);
}
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>
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));
}
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>
`;
}
}
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;
}
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>
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;
}
Navigation and progress bar complete.
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
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);
}
}
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)
}
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
}
}
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;
}
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})`;
});
// ...
}
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
Top comments (3)
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 !
I would try simply
import 'hammerjs'
.No scroll-snap? Denied!
Seriously though, awesome post. What a nice way to celebrate the utter downfall of IE11 :D