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.
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.
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:
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'importstylesfrom'./index.pcss'@customElement('wc-stories-story')classStoryextendsLitElement{/**
* @description image absolute or relative url
*/@property({type:String})src=''/**
* @description checks if an image is available to show
*/@property({type:Boolean})visible=falserender(){returnhtml`
<div class="${this.cssClass}">
<img src="${this.src}" />
</div>
<style>
${styles.toString()}
</style>
`}getcssClass(){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'importstylesfrom'./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')classProgressextendsLitElement{/**
* @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
*/privateanimation:Animationrender(){constimages=Array.from({length:5},(_,i)=>i)returnhtml`
${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()}consti=this.currentIndexconsttrack=this.shadowRoot.querySelector(`#track-${i}`)if (track){constanimProps:PropertyIndexedKeyframes={width:['0%','100%']}constanimOptions: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.
import{LitElement,customElement,property,html}from'lit-element'importstylesfrom'./index.pcss'import{Story}from'../story'import'../progress'@customElement('wc-stories')classWCStoriesextendsLitElement{/**
* @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.
*/privatehandler={onAnimationEnd:()=>{this.startAt=this.startAt<this.children.length-1?this.startAt+1:0this.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:0this.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:0this.renderNewImage()}render(){returnhtml`
<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 (storyinstanceofStory){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:
<!-- 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><htmllang="en"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metahttp-equiv="X-UA-Compatible"content="ie=edge"><!-- Open Sans font --><linkhref="https://fonts.googleapis.com/css?family=Noto+Sans"rel="preload"as="font"><linkhref="https://fonts.googleapis.com/css?family=Noto+Sans"rel="stylesheet"><!-- CSS reset --><linkhref="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 defersrc="index.js"></script><title>WC Stories</title><style>.container{display:flex;justify-content:center;padding:50px;}</style></head><body><mainclass="container"><wc-storiesheight="480"width="320"withShadow><wc-stories-storysrc="img/01.jpg"></wc-stories-story><wc-stories-storysrc="img/02.jpg"></wc-stories-story><wc-stories-storysrc="img/03.jpg"></wc-stories-story><wc-stories-storysrc="img/04.jpg"></wc-stories-story><wc-stories-storysrc="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 β‘.