A foundational overview of how to build an experience similar to Instagram Stories on the web.
In this post I want to share thinking on building a Stories component for the web that is responsive, supports keyboard navigation, and works across browsers.
If you would prefer a hands-on demonstration of building this Stories component yourself, check out the Stories component codelab.
If you prefer video, here's a YouTube version of this post:
Overview
Two popular examples of the Stories UX are Snapchat Stories and Instagram Stories (not to mention fleets). In general UX terms, Stories are usually a mobile-only, tap-centric pattern for navigating multiple subscriptions. For example, on Instagram, users open a friend's story and go through the pictures in it. They generally do this many friends at a time. By tapping on the right side of the device, a user skips ahead to that friend's next story. By swiping right, a user skips ahead to a different friend. A Story component is fairly similar to a carousel, but allows navigating a multi-dimensional array as opposed to a single-dimensional array. It's as if there's a carousel inside each carousel. 🤯
Picking the right tools for the job
All in all I found this component pretty straightforward to build, thanks to a few critical web platform features. Let's cover them!
CSS Grid
Our layout turned out to be no tall order for CSS Grid as it's equipped with some powerful ways to wrangle content.
Friends layout
Our primary .stories
component wrapper is a mobile-first horizontal scrollview:
.stories {
inline-size: 100vw;
block-size: 100vh;
display: grid;
grid: 1fr / auto-flow 100%;
gap: 1ch;
overflow-x: auto;
scroll-snap-type: x mandatory;
overscroll-behavior: contain;
touch-action: pan-x;
}
/* desktop constraint */
@media (hover: hover) and (min-width: 480px) {
max-inline-size: 480px;
max-block-size: 848px;
}
Let's breakdown that grid
layout:
- We explicitly fill the viewport on mobile with
100vh
and100vw
and constrain the size on desktop /
separates our row and column templatesauto-flow
translates togrid-auto-flow: column
- The autoflow template is
100%
, which in this case is whatever the scroll window width is
On a mobile phone, think of this like the row size being the viewport height and each column being the viewport width. Continuing with the Snapchat Stories and Instagram Stories example, each column will be a friend's story. We want friends stories to continue outside of the viewport so we have somewhere to scroll to. Grid will make however many columns it needs to layout your HTML for each friend story, creating a dynamic and responsive scrolling container for us. Grid enabled us to centralize the whole effect.
Stacking
For each friend we need their stories in a pagination-ready state. In preparation for animation and other fun patterns, I chose a stack. When I say stack, I mean like you're looking down on a sandwich, not like you're looking from the side.
With CSS grid, we can define a single-cell grid (i.e. a square), where the rows
and columns share an alias ([story]
), and then each child gets assigned to that
aliased single-cell space:
.user {
display: grid;
grid: [story] 1fr / [story] 1fr;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.story {
grid-area: story;
background-size: cover;
…
}
This puts our HTML in control of the stacking order and also keeps all elements
in flow. Notice how we didn't need to do anything with absolute
positioning or z-index
and
we didn't need to box correct with height: 100%
or width: 100%
. The parent grid
already defined the size of the story picture viewport, so none of these story components
needed to be told to fill it!
CSS Scroll Snap Points
The CSS Scroll Snap Points spec makes it a cinch to lock elements into the viewport on scroll. Before these CSS properties existed, you had to use JavaScript, and it was… tricky, to say the least. Check out Introducing CSS Scroll Snap Points by Sarah Drasner for a great breakdown of how to use them.
.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; }
.user { display: grid; grid: [story] 1fr / [story] 1fr; scroll-snap-align: start; scroll-snap-stop: always; }
I chose Scroll Snap Points for a few reasons:
- Free accessibility. The Scroll Snap Points spec states that pressing the Left Arrow and Right Arrow keys should move through the snap points by default.
- A growing spec. The Scroll Snap Points spec is getting new features and improvements all the time, which means that my Stories component will probably only get better from here on out.
- Ease of implementation. Scroll Snap Points are practically built for the touch-centric horizontal-pagination use case.
- Free platform-style inertia. Every platform will scroll and rest in its style, as opposed to normalized inertia which can have an uncanny scrolling and resting style.
Cross-browser compatibility
We tested on Opera, Firefox, Safari, and Chrome, plus Android and iOS. Here's a brief rundown of the web features where we found differences in capabilities and support.
We did though have some CSS not apply, so some platforms are currently missing out on UX optimizations. I did enjoy not needing to manage these features and feel confident that they'll eventually reach other browsers and platforms.
scroll-snap-stop
Carousels were one of the major UX use cases that prompted the creation of the
CSS Scroll Snap Points spec. Unlike Stories, a carousel doesn't always need to stop
on each image after a user interacts with it. It might be fine or encouraged to
quickly cycle through the carousel. Stories, on the other hand, are best navigated one-by-one,
and that's exactly what scroll-snap-stop
provides.
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
}
At the time of writing this post, scroll-snap-stop
is only supported on Chromium-based
browsers. Check out
Browser compatibility
for updates. It's not a blocker, though. It just means that on unsupported browsers
users can accidentally skip a friend. So users will just have to be more careful, or
we'll need to write JavaScript to ensure that a skipped friend isn't marked as viewed.
Read more in the spec if you're interested.
overscroll-behavior
Have you ever been scrolling through a modal when all of a sudden you
start scrolling the content behind the modal?
overscroll-behavior
lets the developer trap that scroll and never let it
leave. It's nice for all sorts of occasions. My Stories component uses it
to prevent additional swipes and scrolling gestures from leaving the
component.
.stories {
overflow-x: auto;
overscroll-behavior: contain;
}
Safari and Opera were the 2 browsers that didn't support this, and that's totally OK. Those users will get an overscroll experience like they're used to and may never notice this enhancement. I'm personally a big fan and like including it as part of nearly every overscroll feature I implement. It's a harmless addition that can only lead to improved UX.
scrollIntoView({behavior: 'smooth'})
When a user taps or clicks and has reached the end of a friend's set of stories,
it's time to move to the next friend in the scroll snap point set. With
JavaScript, we were able to reference the next friend and request for it to be
scrolled into view. The support for the basics of this are great; every browser
scrolled it into view. But, not every browser did it 'smooth'
. This just means
it's scrolled into view instead of snapped.
element.scrollIntoView({
behavior: 'smooth'
})
Safari was the only browser not to support behavior: 'smooth'
here. Check out
Browser compatibility
for updates.
Hands-on
Now that you know how I did it, how would you?! Let's diversify our approaches and learn all the ways to build on the web. Create a Glitch, tweet me your version, and I'll add it to the Community remixes section below.
Community remixes
- @geoffrich_ with Svelte: demo & code
- @GauteMeekOlsen with Vue: demo + code
- @AnaestheticsApp with Lit: demo & code