Codelab: Building a Stories component

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

  1. Click Remix to Edit to make the project editable.
  2. 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.

Chrome and DevTools open with a grid visual showing the full width layout
Chrome DevTools showing grid column overflow, making a horizontal scroller.

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 text
  • touch-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 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!