Building Instagram/Whatsapp stories clone using Web Components 🔥
Tags: webcomponents, litelement, javascript, webpack
Published: 2019-02-13 16:35:46

{% github gugadev/wc-stories %}

> **Note:** the styles of the components are not pasted in this post to focus on the logic. However, you can find them in the [Github repo](https://github.com/gugadev/wc-stories).

## 🦄 Inspiration

A couple of days ago, I discovered a project called [react-insta-stories](https://github.com/mohitk05/react-insta-stories) from _Mohit Karekar_. I thought it was funny built the same idea but using Web Components instead. So, I pick my computer and started to code. 😉

## 🛠️ Setup

In any project, the first thing you need to do is set up the development environment. In a regular frontend project, we will end up using Webpack as transpiler and bundler. Also, we will use [lit-element](https://github.com/polymer/lit-element) to write our Web Components and PostCSS for styling, with some plugins like _cssnano_.

🛠️ Dev dependencies:

```bash
yarn add --dev webpack webpack-cli webpack-dev-server uglifyjs-webpack-plugin html-webpack-plugin clean-webpack-plugin webpack-merge typescript tslint ts-node ts-loader postcss-loader postcss-preset-env cross-env cssnano jest jest-puppeteer puppeteer npm-run-all
```

⚙️ Runtime dependencies:

```bash
yarn add lit-element core-js @types/webpack @types/webpack-dev-server @types/puppeteer @types/node @types/jest @types/jest-environment-puppeteer @types/expect-puppeteer
```

Our source code must be inside `src/` folder. Also, we need to create a `demo/` folder and put some images inside it.

### Webpack

Let's divide our Webpack configuration into three parts:

- `webpack.common.ts`: provide shared configuration for both environments.
- `webpack.dev.ts`: configuration for development only.
- `webpack.prod.ts`: configuration for production only. Here we've to put some tweaks like bundle optimization.

Let's see those files.

**webpack.common.js**

```typescript
import path from 'path'
import CleanWebpackPlugin from 'clean-webpack-plugin'
import webpack from 'webpack'

const configuration: webpack.Configuration = {
  entry: {
    index: './src/index.ts'
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist')
  },
  resolve: {
    extensions: [
      '.ts',
      '.js'
    ]
  },
  module: {
    rules: [
      {
        test: /\.(ts|js)?$/,
        use: [
          'ts-loader'
        ],
        exclude: [
          /node_modules\/(?!lit-element)/
        ]
      },
      {
        test: /\.pcss?$/,
        use: [
          'css-loader',
          'postcss-loader'
        ]
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(['dist'])
  ]
}

export default configuration
```

This file contains the basic configuration, like, `entry` and `output` settings, rules and a plugin to clean the output folder before each build process.

**webpack.dev.js**

```typescript
import webpack from 'webpack'
import merge from 'webpack-merge'
import HtmlWebpackPlugin from 'html-webpack-plugin'
import common from './webpack.common'

const configuration: webpack.Configuration = {
  mode: 'development',
  devtool: 'inline-source-map',
  devServer: {
    contentBase: './demo',
    publicPath: '/',
    compress: true,
    port: 4444
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: './demo/index.html'
    })
  ]
}

export default merge(common, configuration)
```

The development configuration only adds the webpack-dev-server settings and one extra plugin to use an HTML file as _index.html_ provided for the development server.

**webpack.prod.js**

```typescript
import webpack from 'webpack'
import merge from 'webpack-merge'
import UglifyPlugin from 'uglifyjs-webpack-plugin'
import common from './webpack.common'

const configuration: webpack.Configuration = {
  mode: 'production',
  devtool: 'source-map',
  optimization: {
    minimizer: [
      new UglifyPlugin({
        sourceMap: true,
        uglifyOptions: {
          output: { comments: false }
        }
      })
    ]
  }
}

export default merge(common, configuration)
```

Finally, our production configuration just adjust some 🚀 optimization options using the uglifyjs-webpack-plugin package.

That's all the webpack configuration. The last step is create some scripts into our _package.json_ to run the development server and generate a ⚙️ _production build_:

```json
"start": "cross-env TS_NODE_PROJECT=tsconfig.webpack.json webpack-dev-server --config webpack.dev.ts",
"build": "cross-env TS_NODE_PROJECT=tsconfig.webpack.json webpack --config webpack.prod.ts",
```

### PostCSS

We need to create a `.postcssrc` file at the root of our project with the following content to process correctly our *.pcs files:

```json
{
  "plugins": {
    "postcss-preset-env": {
      "stage": 2,
      "features": {
        "nesting-rules": true
      }
    },
    "cssnano": {}
  }
}
```

### Typescript

And finally, we need to create a `tsconfig.json` file to configure our Typescript environment:

```json
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "allowJs": true,
    "esModuleInterop": true,
    "moduleResolution": "node",
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "sourceMap": true,
    "removeComments": true
  },
  "include": [
    "src/"
  ],
  "exclude": [
    "node_modules/"
  ]
}
```

Aditionally, create a `tsconfig.webpack.json` file that will be used by ts-node to run Webpack using Typescript:

```json
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "esModuleInterop": true
  }
}
```

## 🏭 Structure

[Let's keep things simple](https://en.wikipedia.org/wiki/KISS_principle). We'll need to write three components:

- container
- story
- progress bar

The _container_ is where the logic will be written. Here _we hold the control of which image should be visible, and which not_, also, we need handle the _previous_ and _next_ clicks. The _story_ component is _where the images will be shown_, and the _progress bar_ component, is _where we can visualize the timming_ for the current image.

## 📦 The `<story>` component.

This component is simple, it's just contains a `div` with an `img` inside it. The image's wrapper is necessary to able the animation.

Let's create a `index.ts` file under `stories/` folder, with the following content:

```typescript
import {
  LitElement,
  html,
  customElement,
  property
} from 'lit-element'
import styles from './index.pcss'

@customElement('wc-stories-story')
class Story extends LitElement {

  /**
   * @description image absolute or relative url
   */
  @property({ type: String }) src = ''
  /**
   * @description checks if an image is available to show
   */
  @property({ type: Boolean }) visible = false

  render() {
    return html`
      <div class="${this.cssClass}">
        <img src="${this.src}" />
      </div>
      <style>
        ${styles.toString()}
      </style>
    `
  }

  get cssClass() {
    return [
      'stories__container__story',
      this.visible ? 'visible' : ''
    ].join(' ')
  }
}

export { Story }
```

The anatomy of an Web Component using lit-element is simple. The only **mandatory method you need to implement** is `render`. This method must returns the html content that will be shadowed.

This component, accept two properties. The first, is the relative or absolute URL of the image to show (`src`) and the second one, the flag that notifies the component when it should be shown (`visible`).

You will realize that each component import it's styles from a standalone `.pcss` file, containing the PostCSS code. This is possible thanks to _postcss-loader_ and _style-loader_ webpacks loaders.

That's all 🙂 Easy, right? Let's see our next component.

## 📦 The `<progress>` component

This component is small, but interesting. The responsability of this block is providing an animation for each image. The animation is just a progress bar, ¡using **Web Animations API**!

```typescript
import {
  LitElement,
  html,
  property,
  customElement
} from 'lit-element'
import styles from './index.pcss'
/* Array.from polyfill. The provided by Typescript
 * does not work properly on IE11.
 */
import 'core-js/modules/es6.array.from'

@customElement('wc-stories-progress')
class Progress extends LitElement {

  /**
   * @description count of images
   */
  @property({ type: Number }) segments = 0
  
  /**
   * @description current image index to show
   */
  @property({ type: Number, attribute: 'current' }) currentIndex = 0

  /**
   * @description progress' animation duration
   */
  @property({ type: Number }) duration = 0

  /**
   * @description object that
   * contains the handler for onanimationend event.
   */
  @property({ type: Object }) handler: any = {}

  /**
   * Current animation
   */
  private animation: Animation

  render() {
    const images = Array.from({ length: 5}, (_, i) => i)

    return html`
      ${
        images.map(i => (
          html`
            <section
              class="progress__bar"
              style="width: calc(100% / ${this.segments || 1})"
            >
              <div id="track-${i}" class="bar__track">
              </div>
            </section>
          `
        ))
      }
      <style>
        ${styles.toString()}
      </style>
    `
  }

  /**
   * Called every time this component is updated.
   * An update for this component means that a
   * 'previous' or 'next' was clicked. Because of
   * it, we need to cancel the previous animation
   * in order to run the new one.
   */
  updated() {
    if (this.animation) { this.animation.cancel() }

    const i = this.currentIndex
    const track = this.shadowRoot.querySelector(`#track-${i}`)

    if (track) {
      const animProps: PropertyIndexedKeyframes = {
        width: ['0%', '100%']
      }
      const animOptions: KeyframeAnimationOptions = {
        duration: this.duration
      }
      this.animation = track.animate(animProps, animOptions)
      this.animation.onfinish = this.handler.onAnimationEnd || function () {}
    }
  }
}

export { Progress }
```

This component has the following properties:

- `duration`: duration of the animation.
- `segments`: image's count.
- `current`: current image (index) to show.
- `handler`: object containing the handler for `onanimationend` event.

The _handler_ property is a literal object containing a function called `onAnimationEnd` (you'll see it in the last component). Each time the current animation ends, this funcion is executed on the parent component, updating the current index and showing the next image.

Also, we store the current animation on a variable to ❌ cancel the current animation when need to animate the next bar. Otherwise every animation will be visible all the time.

## 📦 The `<stories>` component

This is our last component. Here we need to handle the flow of the images to determine which image must be shown.

```typescript
import {
  LitElement,
  customElement,
  property,
  html
} from 'lit-element'
import styles from './index.pcss'
import { Story } from '../story'
import '../progress'

@customElement('wc-stories')
class WCStories extends LitElement {

  /**
   * @description
   * Total time in view of each image
   */
  @property({ type: Number }) duration = 5000
  
  /**
   * @description
   * Array of images to show. This must be URLs.
   */
  @property({ type: Array }) images: string[] = []
  
  /**
   * @NoImplemented
   * @description
   * Effect of transition.
   * @version 0.0.1 Only support for fade effect.
   */
  @property({ type: String }) effect = 'fade'
  
  /**
   * @description
   * Initial index of image to show at start
   */
  @property({ type: Number }) startAt = 0
  
  /**
   * @description
   * Enables or disables the shadow of the container
   */
  @property({ type: Boolean }) withShadow = false

  @property({ type: Number }) height = 480

  @property({ type: Number }) width = 320

  /**
   * Handles the animationend event of the
   * <progress> animation variable.
   */
  private handler = {
    onAnimationEnd: () => {
      this.startAt = 
        this.startAt < this.children.length - 1
        ? this.startAt + 1
        : 0
      this.renderNewImage()
    }
  }

  /**
   * When tap on left part of the card,
   * it shows the previous story if any
   */
  goPrevious = () => {
    this.startAt = 
      this.startAt > 0
      ? this.startAt - 1
      : 0
    this.renderNewImage()
  }

  /**
   * When tap on right part of the card,
   * it shows the next story if any, else
   * shows the first one.
   */
  goNext = () => {
    this.startAt = 
      this.startAt < this.children.length - 1
      ? this.startAt + 1
      : 0
    this.renderNewImage()
  }

  render() {
    return html`
      <wc-stories-progress
        segments="${this.images.length}"
        duration="${this.duration}"
        current="${this.startAt}"
        .handler="${this.handler}"
      >
      </wc-stories-progress>
      <section class="touch-panel">
        <div @click="${this.goPrevious}"></div>
        <div @click="${this.goNext}"></div>
      </section>
      <!-- Children -->
      <slot></slot>
      <style>
        ${styles.toString()}
        :host {
          box-shadow: ${
            this.withShadow
            ? '0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22);'
            : 'none;'
          }
          height: ${this.height}px;
          width: ${this.width}px;
        }
      </style>
    `
  }

  firstUpdated() {
    this.renderNewImage()
  }

  /**
   * Iterate over children stories to know
   * which story we need to render.
   */
  renderNewImage() {
    Array.from(this.children).forEach((story: Story, i) => {
      if (story instanceof Story) {
        story.visible = this.startAt === i
      }
    })
  }
}

export { WCStories }
```

Our main component accepts the initial configuration through some properties:

- `duration`: how much time the image will be visible.
- `startAt`: image to show at start up.
- `height`: self-explanatory.
- `width`: self-explanatory.
- `withShadow`: enables or disables drop shadow.

Also, it has some methods to control the transition flow:

- `goPrevious`: show the previous image.
- `goNext`: show the next image.
- `renderNewImage`: iterate over the stories components and resolve, through a comparation between the index and the `startAt` property, which image must be shown.


All the stories are the children of this component, placed inside an slot:

```html
<!-- Children -->
<slot></slot>
```

When the Shadow DOM is built, all the children will be inserted inside the slot.

## 🚀 Time to run!

Create an `index.html` file inside a `demo/` folder at the project root with the content below:

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <!-- Open Sans font -->
  <link href="https://fonts.googleapis.com/css?family=Noto+Sans" rel="preload" as="font">
  <link href="https://fonts.googleapis.com/css?family=Noto+Sans" rel="stylesheet">
  <!-- CSS reset -->
  <link href="https://necolas.github.io/normalize.css/8.0.1/normalize.css" rel="stylesheet">
  <!-- polyfills -->
  <script src="https://unpkg.com/web-animations-js@2.3.1/web-animations.min.js"></script>
  <script src="https://unpkg.com/@webcomponents/webcomponentsjs@2.2.7/custom-elements-es5-adapter.js"></script>
  <script src="https://unpkg.com/@webcomponents/webcomponentsjs@2.2.7/webcomponents-loader.js"></script>
  <!-- our script -->
  <script defer src="index.js"></script>
  <title>WC Stories</title>
  <style>
  .container {
    display: flex;
    justify-content: center;
    padding: 50px;
  }
  </style>
</head>
<body>
  <main class="container">
    <wc-stories height="480" width="320" withShadow>
      <wc-stories-story src="img/01.jpg"></wc-stories-story>
      <wc-stories-story src="img/02.jpg"></wc-stories-story>
      <wc-stories-story src="img/03.jpg"></wc-stories-story>
      <wc-stories-story src="img/04.jpg"></wc-stories-story>
      <wc-stories-story src="img/05.jpg"></wc-stories-story>
    </wc-stories>
  </main>
</body>
</html>
```

Hold this position and create a folder called `img/`, inside paste some images. Note that you need to map each of your images as a `<wc-stories-story>` component. In my case, I have 5 images called _01.jpg_, _02.jpg_ and so on.

Once we did this step, we're ready to start our development server. Run the `yarn start` command and go to _localhost:4444_. You will see something [like this](https://gugadev.github.io/wc-stories).

## ✈️ Bonus: definitive proof

The main goal of Web Components is **create reusable UI pieces that works on any web-powered platform**, and this, of course, **include frontend frameworks**. So, let's see how this component works on major frameworks out there: React, Angular and vue.

### React

{% stackblitz react-wcstories view=preview %}

### Vue

{% stackblitz vue-wcstories view=preview %}

### Angular

{% stackblitz angular-wcstories view=preview %}

Cool! it's works! 😃 🎉

---

## 🤔 Conclusion

Advice: **learn, adopt, use and write** Web Components. You can use it with Vanilla JS or frameworks like above. Are **native and standarized**, easy to understand and write 🤓, powerful 💪 and have an excellent performance ⚡.