loading...
Cover image for Building Instagram/Whatsapp stories clone using Web Components πŸ”₯

Building Instagram/Whatsapp stories clone using Web Components πŸ”₯

gugadev profile image Gus Garzaki ・8 min read

GitHub logo gugadev / storify

Instagram/Whatsapp stories clone built on Web Components and Web Animations API. πŸ”₯

wc 🌐 stories

Instagram/Whatsapp stories like built on Web Components and Web Animations API

Demos


Vanilla JS

Angular

React

Vue
Link Link Link Link

Browser support

IE / Edge
IE / Edge
Firefox
Firefox
Chrome
Chrome
Safari
Safari
Opera
Opera
IE11, Edge last 10 versions last 10 versions last 10 versions last 10 versions

πŸ“¦ Install

npm i @gugadev
/wc-stories
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ What's the prupose of it?

Just fun πŸ™‚. I love learn and code, so, this every time I have free time, pick some crazy idea or got inspiration from another projects and make it. πŸ˜‹

πŸ¦„ Inspiration

When I saw the project of Mohit, react-insta-stories, immediately wanted to know how complicated it would be to do the same thing using Web Components. So, I built this. Thanks, Mohit! πŸ˜ƒ

βš™οΈ How it works?

There are three components working together:

  • <wc-stories-story>: this component shows a image. The maximun size of an image is the…

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.

πŸ¦„ Inspiration

A couple of days ago, I discovered a project called 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 to write our Web Components and PostCSS for styling, with some plugins like cssnano.

πŸ› οΈ Dev dependencies:

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

βš™οΈ Runtime dependencies:

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

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

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

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

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

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

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

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:

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

PostCSS

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

{
  "plugins": {
    "postcss-preset-env": {
      "stage": 2,
      "features": {
        "nesting-rules": true
      }
    },
    "cssnano": {}
  }
}
Enter fullscreen mode Exit fullscreen mode

Typescript

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

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

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

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "esModuleInterop": true
  }
}
Enter fullscreen mode Exit fullscreen mode

🏭 Structure

Let's keep things simple. 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:

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

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!

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

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.

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

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:

<!-- Children -->
<slot></slot>
Enter fullscreen mode Exit fullscreen mode

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:

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

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.

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

Vue

Angular

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

Discussion

pic
Editor guide