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
Browser support
π¦ Install
npm i @gugadev/wc-stories
π‘ 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 containersβ¦
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
βοΈ 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
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
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)
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)
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",
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": {}
}
}
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/"
]
}
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
}
}
π 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 }
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 }
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 foronanimationend
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 }
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 thestartAt
property, which image must be shown.
All the stories are the children of this component, placed inside an slot:
<!-- 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:
<!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.
βοΈ 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 β‘.
Top comments (0)