This codelab teaches you how to build an experience like Instagram Stories on the web. We'll build the component as we go, starting with HTML, then CSS, then JavaScript.
Check out my blog post Building a Stories component to learn about the progressive enhancements made while building this component.
Setup
- Click Remix to Edit to make the project editable.
- Open
app/index.html
.
HTML
I always aim to use semantic HTML.
Since each friend can have any number of stories, I thought it was meaningful to use a
<section>
element for each friend and an <article>
element for each story.
Let's start from the beginning though. First, we need a container for our
stories component.
Add a <div>
element to your <body>
:
<div class="stories">
</div>
Add some <section>
elements to represent friends:
<div class="stories">
<section class="user"></section>
<section class="user"></section>
<section class="user"></section>
<section class="user"></section>
</div>
Add some <article>
elements to represent stories:
<div class="stories">
<section class="user">
<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
<article class="story" style="--bg: url(https://picsum.photos/480/841);"></article>
</section>
<section class="user">
<article class="story" style="--bg: url(https://picsum.photos/481/840);"></article>
</section>
<section class="user">
<article class="story" style="--bg: url(https://picsum.photos/481/841);"></article>
</section>
<section class="user">
<article class="story" style="--bg: url(https://picsum.photos/482/840);"></article>
<article class="story" style="--bg: url(https://picsum.photos/482/843);"></article>
<article class="story" style="--bg: url(https://picsum.photos/482/844);"></article>
</section>
</div>
- We're using an image service (
picsum.com
) to help prototype stories. - The
style
attribute on each<article>
is part of a placeholder loading technique, which you'll learn more about in the next section.
CSS
Our content is ready for style. Let's turn those bones into something folks will want to interact with. We'll be working mobile-first today.
.stories
For our <div class="stories">
container we want a horizontal scrolling container.
We can achieve this by:
- Making the container a Grid
- Setting each child to fill the row track
- Making the width of each child the width of a mobile device viewport
Grid will continue placing new 100vw
-wide columns to the right of the previous
one, until it's placed all the HTML elements in your markup.
Add the following CSS to the bottom of app/css/index.css
:
.stories {
display: grid;
grid: 1fr / auto-flow 100%;
gap: 1ch;
}
Now that we have content extending beyond the viewport, it's time to tell that
container how to handle it. Add the highlighted lines of code to your .stories
ruleset:
.stories {
display: grid;
grid: 1fr / auto-flow 100%;
gap: 1ch;
overflow-x: auto;
scroll-snap-type: x mandatory;
overscroll-behavior: contain;
touch-action: pan-x;
}
We want horizontal scrolling, so we'll set overflow-x
to
auto
. When the user scrolls we want the component to gently rest on the next story,
so we'll use scroll-snap-type: x mandatory
. Read more about this
CSS in the CSS Scroll Snap Points
and overscroll-behavior
sections of my blog post.
It takes both the parent container and the children to agree to scroll snapping, so
let's handle that now. Add the following code to the bottom of app/css/index.css
:
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
}
Your app doesn't work yet, but the video below shows what happens when
scroll-snap-type
is enabled and disabled. When enabled, each horizontal
scroll snaps to the next story. When disabled, the browser uses its
default scrolling behavior.
That will get you scrolling through your friends, but we still have an issue with the stories to solve.
.user
Let's create a layout in the .user
section that wrangles those child story
elements into place. We're going to use a handy stacking trick to solve this.
We're essentially creating a 1x1 grid where the row and column have the same Grid
alias of [story]
, and each story grid item is going to try and claim that space,
resulting in a stack.
Add the highlighted code to your .user
ruleset:
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
display: grid;
grid: [story] 1fr / [story] 1fr;
}
Add the following ruleset to the bottom of app/css/index.css
:
.story {
grid-area: story;
}
Now, without absolute positioning, floats, or other layout directives that take an element out of flow, we're still in flow. Plus, it's like barely any code, look at that! This gets broken down in the video and the blog post in more detail.
.story
Now we just need to style the story item itself.
Earlier we mentioned that the style
attribute on each <article>
element is part of a
placeholder loading technique:
<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
We're going to use CSS's background-image
property, which allows us to specify
more than one background image. We can put them in an order so that our user
picture is on top and will show up automatically when it's done loading. To
enable this, we'll put our image URL into a custom property (--bg
), and use it
within our CSS to layer with the loading placeholder.
First, let's update the .story
ruleset to replace a gradient with a background image
once it's done loading. Add the highlighted code to your .story
ruleset:
.story {
grid-area: story;
background-size: cover;
background-image:
var(--bg),
linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}
Setting background-size
to cover
ensures there's no empty space in the
viewport because our image will be filling it up. Defining 2 background images
enables us to pull a neat CSS web trick called the loading tombstone:
- Background image 1 (
var(--bg)
) is the URL we passed inline in the HTML - Background image 2 (
linear-gradient(to top, lch(98 0 0), lch(90 0 0))
is a gradient to show while the URL is loading
CSS will automatically replace the gradient with the image, once the image is done downloading.
Next we'll add some CSS to remove some behavior, freeing up the browser to move faster.
Add the highlighted code to your .story
ruleset:
.story {
grid-area: story;
background-size: cover;
background-image:
var(--bg),
linear-gradient(to top, lch(98 0 0), lch(90 0 0));
user-select: none;
touch-action: manipulation;
}
user-select: none
prevents users from accidentally selecting texttouch-action: manipulation
instructs the browser that these interactions should be treated as touch events, which frees up the browser from trying to decide whether you're clicking a URL or not
Last, let's add a little CSS to animate the transition between stories. Add the
highlighted code to your .story
ruleset:
.story {
grid-area: story;
background-size: cover;
background-image:
var(--bg),
linear-gradient(to top, lch(98 0 0), lch(90 0 0));
user-select: none;
touch-action: manipulation;
transition: opacity .3s cubic-bezier(0.4, 0.0, 1, 1);
&.seen {
opacity: 0;
pointer-events: none;
}
}
The .seen
class will be added to a story that needs an exit.
I got the custom easing function (cubic-bezier(0.4, 0.0, 1,1)
)
from Material Design's Easing
guide (scroll to the Accerlerated easing section).
If you've got a keen eye you probably noticed the pointer-events: none
declaration and are scratching your head right now. I'd say this is the only
downside of the solution so far. We need this because a .seen.story
element
will be on top and will receive taps, even though it's invisible. By setting the
pointer-events
to none
, we turn the glass story into a window, and steal no
more user interactions. Not too bad of a trade off, not too hard to manage here
in our CSS right now. We're not juggling z-index
. I'm feeling good about this
still.
JavaScript
The interactions of a Stories component are quite simple to the user: tap on the right to go forward, tap on the left to go back. Simple things for users tends to be hard work for developers. We'll take care of lots of it, though.
Setup
To start out, let's compute and store as much information as we can.
Add the following code to app/js/index.js
:
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
Our first line of JavaScript grabs and stores a reference to our primary HTML element root. The next line calculates where the middle of our element is, so we can decide if a tap is to go forward or backward.
State
Next we make a small object with some state relevant to our logic. In this
case, we're only interested in the current story. In our HTML markup, we can
access it by grabbing the 1st friend and their most recent story. Add the highlighted code
to your app/js/index.js
:
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
const state = {
current_story: stories.firstElementChild.lastElementChild
}
Listeners
We have enough logic now to start listening for user events and directing them.
Mouse
Let's start by listening to the 'click'
event on our stories container.
Add the highlighted code to app/js/index.js
:
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
const state = {
current_story: stories.firstElementChild.lastElementChild
}
stories.addEventListener('click', e => {
if (e.target.nodeName !== 'ARTICLE')
return
navigateStories(
e.clientX > median
? 'next'
: 'prev')
})
If a click happens and it's not on a <article>
element we bail and do nothing.
If it is an article, we grab the horizontal position of the mouse or finger with
clientX
. We haven't implemented navigateStories
yet, but the argument that
it takes specifies what direction we need to go. If that user position is
greater than the median, we know we need to navigate to next
, otherwise
prev
(previous).
Keyboard
Now, let's listen for keyboard presses. If the Down Arrow is pressed we navigate
to next
. If it's the Up Arrow, we go to prev
.
Add the highlighted code to app/js/index.js
:
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
const state = {
current_story: stories.firstElementChild.lastElementChild
}
stories.addEventListener('click', e => {
if (e.target.nodeName !== 'ARTICLE')
return
navigateStories(
e.clientX > median
? 'next'
: 'prev')
})
document.addEventListener('keydown', ({key}) => {
if (key !== 'ArrowDown' || key !== 'ArrowUp')
navigateStories(
key === 'ArrowDown'
? 'next'
: 'prev')
})
Stories navigation
Time to tackle the unique business logic of stories and the UX they've become famous for. This looks chunky and tricky, but I think if you take it line by line, you'll find it's quite digestible.
Upfront, we stash some selectors that help us decide whether to scroll to a friend or show/hide a story. Since the HTML is where we're working, we'll be querying it for presence of friends (users) or stories (story).
These variables will help us answer questions like, "given story x, does "next" mean move to another story from this same friend or to a different friend?" I did it by using the tree structure we built, reaching into parents and their children.
Add the following code to the bottom of app/js/index.js
:
const navigateStories = direction => {
const story = state.current_story
const lastItemInUserStory = story.parentNode.firstElementChild
const firstItemInUserStory = story.parentNode.lastElementChild
const hasNextUserStory = story.parentElement.nextElementSibling
const hasPrevUserStory = story.parentElement.previousElementSibling
}
Here's our business logic goal, as close to natural language as possible:
- Decide how to handle the tap
- If there's a next/previous story: show that story
- If it's the last/first story of the friend: show a new friend
- If there's no story to go to in that direction: do nothing
- Stash the new current story into
state
Add the highlighted code to your navigateStories
function:
const navigateStories = direction => {
const story = state.current_story
const lastItemInUserStory = story.parentNode.firstElementChild
const firstItemInUserStory = story.parentNode.lastElementChild
const hasNextUserStory = story.parentElement.nextElementSibling
const hasPrevUserStory = story.parentElement.previousElementSibling
if (direction === 'next') {
if (lastItemInUserStory === story && !hasNextUserStory)
return
else if (lastItemInUserStory === story && hasNextUserStory) {
state.current_story = story.parentElement.nextElementSibling.lastElementChild
story.parentElement.nextElementSibling.scrollIntoView({
behavior: 'smooth'
})
}
else {
story.classList.add('seen')
state.current_story = story.previousElementSibling
}
}
else if(direction === 'prev') {
if (firstItemInUserStory === story && !hasPrevUserStory)
return
else if (firstItemInUserStory === story && hasPrevUserStory) {
state.current_story = story.parentElement.previousElementSibling.firstElementChild
story.parentElement.previousElementSibling.scrollIntoView({
behavior: 'smooth'
})
}
else {
story.nextElementSibling.classList.remove('seen')
state.current_story = story.nextElementSibling
}
}
}
Try it out
- To preview the site, press View App. Then press Fullscreen .
Conclusion
That's a wrap up for the needs I had with the component. Feel free to build upon it, drive it with data, and in general make it yours!