Building a Stories component from scratch
This codelab walks through building an Instagram Stories-style experience with progressive enhancement: semantic HTML first, then CSS for layout and motion, and finally JavaScript for tap and keyboard navigation.
Start with the markup
Semantic HTML gives the structure meaning: a <section> for each friend and an <article> for each individual story. Begin with a container in the <body>:
<div class="stories">
</div>
Inside it, add <section> elements for friends:
<div class="stories">
<section class="user"></section>
<section class="user"></section>
<section class="user"></section>
<section class="user"></section>
</div>
Then <article> elements for 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>
Note that the inline style attributes on the articles support a CSS placeholder-loading technique covered next. The image URLs from picsum.com are for prototyping.
CSS: horizontal scroll and snap
The styling works mobile-first. The container becomes a grid where each child takes a full viewport-width column, letting Grid lay out the sections horizontally beyond the screen edge.
app/css/index.css:
.stories {
display: grid;
grid: 1fr / auto-flow 100%;
gap: 1ch;
}
Then add overflow handling and scroll snap to the same ruleset so the container rests on a new story after each swipe:
.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;
}
Scroll snapping requires both container and children to opt in. Add child-side snapping rules:
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
}
With scroll-snap-type: x mandatory and overflow-x: auto, horizontal swipes land cleanly on the next story. Without them, the browser defaults to free scrolling.
Stacking stories per user
The .user section needs to layer multiple story articles in the same place. A 1x1 grid with an alias for the row and column track creates a stack: every grid item claims the same space.
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
display: grid;
grid: [story] 1fr / [story] 1fr;
}
Add the corresponding grid item rules:
.story {
grid-area: story;
}
This approach keeps elements in normal flow, avoiding absolute positioning, floats, or z-index juggling.
Story styling and loading placeholder
Each story uses CSS multiple backgrounds to implement a "loading tombstone": the real image URL comes from a custom property (--bg) set inline in the HTML, while a gradient shows underneath until the image has loaded.
<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
CSS swaps the gradient for the fetched image automatically. Update .story to layer these backgrounds and use background-size: cover:
.story {
grid-area: story;
background-size: cover;
background-image:
var(--bg),
linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}
Next, disable default text selection and behavior handling so interactions feel native:
.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;
}
Finally, add a transition for when a story is dismissed via the .seen class:
.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 easing curve cubic-bezier(0.4, 0.0, 1,1) comes from Material Design's accelerated easing guide. The pointer-events: none on the exiting story lets taps pass through it to the content underneath, since the invisible element would otherwise intercept them.
JavaScript for navigation
Grab reusable values
Start by storing a reference to the components container and computing the viewport midpoint, which will determine tap direction:
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
Track current story
A tiny state object keeps track of the active story, initialized to the newest one of the first friend:
const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)
const state = {
current_story: stories.firstElementChild.lastElementChild
}
Wire up input listeners
On a click anywhere within the container, the handler checks whether the tap target is an <article>. If so, it compares the pointer's clientX to the stored midpoint and routes to next or prev:
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')
})
Keyboard navigation follows the same pattern, with the Down Arrow and Up Arrow mapped to the same directions:
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')
})
Resolve the destination story
The core navigation logic reads relationships between siblings and parents in the DOM to decide what to show. Querying for users and stories inside the tree answers the question "does next mean the next story of this user, or the first story of the next user?"
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
}
Add the navigation logic to the 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
}
}
}
The behavior mirrors the familiar stories UX:
- When there is a next/previous story, show it and mark the old one as seen.
- When the end or beginning of a user's stories is reached, switch to the adjacent user's stories.
- When no destination exists in that direction, do nothing.
Finally, persist the new active story in state.
That completes a self-contained, progressively enhanced component ready to be extended with data-driven content.



