Design an Instagram-style stories viewer
A reusable full-screen viewer that plays a tray of users' short photo and video stories in order, with segmented progress bars, tap zones, hold to pause, and swipes between users. The hard parts are a clock that pauses and resumes exactly for many reasons at once, telling taps from holds from swipes, preloading just enough media, and counting a story as seen honestly.
The question
Design a stories viewer component. A row of round avatars sits at the top of a feed. Tapping one opens a full-screen viewer that plays that person's stories one after another: photos for a few seconds each, videos for their own length, with a thin progress bar per story at the top. When one person's stories end, the next person's start.
The user can tap the right side to skip, the left side to go back, hold to pause, swipe sideways to change person, swipe down to close, and reply to a story. Build it as a component other teams can drop into their pages. Walk me through the timing, the gestures, the preloading, and how you'd make it accessible.
Real-life examples: Instagram Stories, Snapchat Stories, WhatsApp Status, YouTube and LinkedIn stories, Telegram Stories
Requirements, and what makes this hard
A stories viewer looks like a slideshow with a timer. It is really a small media player, a gesture engine, a preloader, and a modal dialog sharing one screen, all driven by a clock that must stop the instant anything gets in the way. Most of the design comes from questions about time, input, and media, so ask those first.
This is a component question, not an app question. The interviewer wants to see the viewer's internals and its API, not the feed around it. The Instagram guide places stories inside the whole app in one chapter; here we go deep on the viewer itself and only touch the tray where it feeds the viewer.
| Question | Answer we assume | What it changes |
|---|---|---|
| What media types? | Photos and short videos, up to 60 seconds each, vertical 9:16. Text and stickers are baked into the media by the creator tool. | Two timing sources: a fixed clock for photos, the video's own currentTime for clips. |
| How long is a photo story? | 5 seconds, configurable per item by the server. The exact number is a product choice. | The progress bar fills over a duration the item carries, not a constant in the code. |
| How many users and items? | The tray holds up to 100 users with active stories; each user has 1 to 30 items, a median of 4. | We fetch items per user on demand, never all 400 at once. |
| Which devices? | Phones first (mobile web, iOS Safari and Android Chrome), then desktop with mouse and keyboard. | Touch gestures are primary; keyboard and buttons must reach every action too. |
| Which networks? | Wi-Fi down to slow 4G, about 1.5 Mbps with 150 ms round trips. | Preload depth, image sizes, and what the clock does while media buffers. |
| Who uses the component? | Several product teams: the home feed, profile pages, and a highlights page that replays saved stories. | A small, stable props API with callbacks instead of hard-wired network calls. |
| Do seen states matter? | Yes. The tray ring turns grey when all of a user's stories are seen, and creators see a viewer list. | A clear rule for when an item counts as seen, plus batched, reliable reporting. |
| Replies and reactions? | A reply box and a row of quick emoji reactions on other people's stories. | Typing must pause playback; sends are optimistic. |
Functional requirements
- Open the viewer at a given user (from the tray or a link) and start at that user's first unseen item.
- Play items in order with one progress segment per item. Photos last their duration, videos play to their end.
- Tap the right part of the screen for the next item, the left third for the previous one. At the last item, move to the next user; at the last user, close.
- Press and hold to pause; release to resume. Swipe left or right to jump to the next or previous user. Swipe down to close. Swipe up to open the story's link or reply sheet.
- Pause by itself when the tab is hidden, the window loses focus, the reply box is focused, or another dialog opens on top.
- Mark items as seen and update the tray rings. Send replies and quick reactions.
- Every action works with a keyboard and a screen reader, and the user can pause auto-advance at any time.
Non-functional requirements
| Quality | Target | Why this number |
|---|---|---|
| Next item appears | Under 100 ms after a tap when preloaded | About 100 ms is where a response stops feeling instant. A visible spinner between photos breaks the flow. |
| Clock accuracy | A 5 s photo shows for 5 s of visible, unpaused time, within one frame (about 16 ms) | Users notice a story that skips early, and creators notice when viewers miss the end of a clip. |
| Transitions | 60 frames per second for the user-to-user swipe on a mid-range phone | A dropped frame during a drag feels like the screen is stuck to the finger. |
| Memory | At most about 5 decoded media items and 3 video elements alive at once | Low-end phones kill tabs that hold too many decoded images or video decoders. |
| Data | Preload at most 2 items ahead | Many people watch only a few stories; each wasted video is 0.5 to 2 MB of their data plan. |
| Accessibility | WCAG 2.2 AA, including 2.2.2 Pause, Stop, Hide | Content that advances or moves on its own, next to other content, needs a way to pause it. |
Out of scope
- Creating stories (camera, editing, stickers, upload). That is an upload problem; the file uploader guide covers it.
- Ranking the tray order. The server sends it already sorted.
- Live video and interactive stickers like polls. We mention where they'd plug in, but don't design them.
- The full media pipeline (encoding, adaptive streaming). The video player and YouTube guides cover it.
Check your understanding · Exercise 1
Which of these answers would change the viewer's design the most? Select all that apply.
What makes this hard
Five properties drive every decision in this guide. Name them early in the interview; the later chapters answer them one by one.
- It is time-based. The UI moves on its own. A photo advances after 5 s, a video at its end. Anything that interrupts the user (a hold, a hidden tab, a stalled download, a focused reply box) must freeze time exactly, and resuming must continue from the same point, not restart it.
- One surface, many gestures. A single full-screen area must tell a tap from a hold from a swipe, in three directions, using only where and how long the finger moved. Get the thresholds wrong and every hold also skips a story.
- Two levels of navigation. Items inside a user, and users inside a tray. Next at the last item means next user. Previous at the first item means the previous user's last item. Every edge needs a rule.
- Media must be ready before it's needed. The next item should already be decoded when the current one ends. But fast skipping makes most preloads waste, and a phone can only hold a few decoded images and video decoders at once.
- It is a modal, auto-advancing, visual experience. That means a focus trap, a back button that closes it, a pause control for WCAG 2.2.2, announcements for screen readers, and alt text for content that is mostly pictures.
A quick sense of scale. Someone who taps through 8 users with 4 items each meets 32 items. At about 120 KB for a full-screen photo and about 1 MB for a 15 s video, a session where a third of the items are videos moves roughly 13 MB if every item is watched.
Preloading 2 items ahead wastes at most 2 items when the user closes. Preloading every item of all 100 users in the tray (about 400 items) would cost well over 100 MB. That budget is why the preloading chapter keeps its window small.
Think first
An image carousel already has slides, swipes, and auto-advance. Why can't we just reuse it for stories?
A carousel has one level of slides and usually one timer it can reset. Stories need two levels (items and users), a clock that switches source between a timer and a video's currentTime, and many independent reasons to pause.
A carousel's swipe moves between slides. In stories, a horizontal swipe moves between users, while taps move between items, and vertical swipes close or open a sheet. The gesture map is different.
Reuse the carousel's pointer handling ideas and its transform-based track (the image carousel guide covers both), but build the playback model fresh.
Timing and progress bars: one clock that pauses exactly
Each user's stories show a row of thin bars at the top, one segment per item. Past items are full, future items are empty, and the current one fills as time passes. When it is full, the viewer advances. This chapter is about the clock behind that fill.
The naive timer and how it breaks
The first idea is setInterval(tick, 100) that adds 100 ms to a counter, with a CSS transition on the bar. Three things go wrong. First, browsers throttle timers in background tabs (Chrome runs them at most once per second, and chained timers as rarely as once a minute after 5 minutes hidden), so the counter drifts and items skip while hidden. Second, a hold that starts 40 ms into an interval loses or gains up to 100 ms each time, and ten holds add up to a visible jump.
Third, the CSS transition and the counter are two clocks. After a pause they disagree, so the bar is full while the item is still showing, or the item advances with the bar at 90%.
Decision What should drive a photo item's progress?
What we pick: requestAnimationFrame with accumulated elapsed time. The Instagram guide reached the same choice; here we go into the details that make it exact: pause reasons, frame-gap caps, video as a clock source, and writing the bar without re-rendering.
Pause reasons: a set, not a boolean
The viewer can be paused for many reasons at once: the finger is down, the tab is hidden, the reply box has focus, the video is buffering, the next image isn't decoded yet, a share sheet is open, the user pressed the pause button. A single paused boolean breaks the moment two overlap. If the video stalls while the user holds, then the user lets go, a boolean says play, and the bar runs while the screen is frozen.
Instead, keep a set of pause reasons. Each source adds its own reason and removes only its own. The clock runs only when the set is empty. This makes every source independent: the gesture code never needs to know about buffering, and the visibility code never needs to know about the reply box.
type Reason = "hold" | "hidden" | "blur" | "reply" | "modal" | "buffering" | "loading" | "user";
class StoryClock {
private reasons = new Set<Reason>();
private elapsed = 0;
private last = 0;
constructor(private duration: number, private onFrame: (p: number) => void,
private onEnd: () => void, private video?: HTMLVideoElement) {}
pause(r: Reason) { this.reasons.add(r); this.video?.pause(); }
resume(r: Reason) {
this.reasons.delete(r);
if (this.reasons.size === 0) this.video?.play().catch(() => this.pause("user"));
}
tick = (now: number) => {
const dt = Math.min(now - (this.last || now), 100);
this.last = now;
if (this.reasons.size === 0) {
this.elapsed = this.video ? this.video.currentTime * 1000 : this.elapsed + dt;
const total = this.video?.duration ? this.video.duration * 1000 : this.duration;
this.onFrame(Math.min(this.elapsed / total, 1));
if (!this.video && this.elapsed >= total) return this.onEnd();
}
requestAnimationFrame(this.tick);
};
}
Three details matter. The gap cap (Math.min(..., 100)): requestAnimationFrame stops firing in hidden tabs, so the first frame after the user returns can be minutes after the last one; without the cap the item would jump to its end. The video branch reads currentTime rather than adding frame time, so the bar can never run ahead of what is on screen; the video's own ended event advances it.
Third, play() returns a promise that can reject (autoplay blocked, source failed), so a rejection becomes a user pause that shows the play button instead of a silently stuck bar.
The now that requestAnimationFrame passes to its callback is a high-resolution timestamp on the same timeline as performance.now(). That clock only moves forward, unlike Date.now(), which jumps when the device's clock is corrected. Until the video's metadata arrives, video.duration is NaN, so the clock falls back to the durationMs the server sent.
Buffering pauses the clock
For videos, listen for waiting (playback stopped because the next frame isn't available) and add buffering; remove it on playing. Since the bar reads currentTime, it freezes by itself during a stall, but the reason still matters: it shows a spinner after a short delay (about 300 ms, so quick stalls don't flash), and it stops the seen timer from counting. For photos, the equivalent is loading: the clock does not start until the image's decode() promise resolves, so a slow image never eats into its own 5 seconds.
Drawing the bars without re-rendering
Calling a React state setter 60 times a second to fill a bar re-renders the viewer 60 times a second. Instead, keep a ref to the active segment's fill element and write el.style.transform = scaleX(p) in onFrame. Transforms are composited, so the browser skips layout and paint for the bar.
React state changes only when the cursor changes, a few times a minute. Past segments render with scaleX(1), future ones with scaleX(0), and the active one is driven by the clock.
Try it below. Tap the zones, hold, hide the tab, and stall the video. Watch the reasons set and the elapsed time: the bar only moves when the set is empty.
Tap the right part of the story for next, the left third for previous, or press and hold to pause.
Check your understanding · Exercise 3
A video item is buffering. While it buffers, the user presses and holds, then releases after 2 seconds. The video is still buffering. What should happen? Select all that apply.
Gestures and tap zones: tap, hold, or swipe
The whole screen is one input surface. A finger touching it could mean five different things, and the viewer only knows which one after it watches what the finger does next. That is a gesture recognizer: a small state machine fed by pointer events.
| Gesture | How it's recognized | Action |
|---|---|---|
| Tap | Up within 250 ms and moved less than 10 px | Left third: previous item. Right two thirds: next item. |
| Hold | Still down after 250 ms, moved less than 10 px | Pause while held, hide the overlays; resume on release. |
| Horizontal swipe | Moved 10 px or more, mostly sideways | Drag the user transition; commit to next or previous user on release. |
| Swipe down | Moved 10 px or more, mostly downward | Drag the viewer down and shrink it; close on release past the threshold. |
| Swipe up | Moved 10 px or more, mostly upward | Open the link or the reply sheet, if the item has one. |
Why the left third and not half? People likely tap forward far more than back, and a right-handed thumb rests near the right side, so a bigger forward zone means fewer accidental rewinds. The 250 ms and 10 px numbers match common gesture library defaults: Hammer.js, for example, accepts a tap only under 250 ms and starts a pan after 10 px of movement. Tune them with real usage data, and keep them as props so teams can adjust.
Decision How do we receive and interpret the input?
What we pick: Pointer Events with a small recognizer, the same base the image carousel guide uses for its swipes. A library like @use-gesture is a fine shortcut; what matters in the interview is that you can explain the thresholds and states it implements.
Pause on pointer down, decide on pointer up
Here's the trick that makes holds feel instant: add the hold pause reason on pointerdown, before you know what the gesture is. If it turns out to be a tap, the item changes anyway, and a 200 ms pause on an item you are leaving costs nothing.
If it is a hold, the pause already started at the first touch, not 250 ms later. If it becomes a swipe, the story should be paused during the drag too. The only thing that waits for the 250 ms timer is hiding the overlays, so a quick tap doesn't make them flicker.
const TAP_MS = 250, SLOP = 10;
let start: { x: number; y: number; t: number; id: number } | null = null;
let mode: "pending" | "hold" | "swipeX" | "swipeY" = "pending";
surface.addEventListener("pointerdown", (e) => {
if (start) return; // ignore a second finger
surface.setPointerCapture(e.pointerId);
start = { x: e.clientX, y: e.clientY, t: e.timeStamp, id: e.pointerId };
mode = "pending";
clock.pause("hold");
holdTimer = setTimeout(() => { if (mode === "pending") { mode = "hold"; hideOverlays(); } }, TAP_MS);
});
surface.addEventListener("pointermove", (e) => {
if (!start || e.pointerId !== start.id) return;
const dx = e.clientX - start.x, dy = e.clientY - start.y;
if (mode === "pending" && Math.hypot(dx, dy) >= SLOP)
mode = Math.abs(dx) > Math.abs(dy) ? "swipeX" : "swipeY";
if (mode === "swipeX") transition.drag(dx);
if (mode === "swipeY") dismiss.drag(dy);
});
surface.addEventListener("pointerup", (e) => {
if (!start || e.pointerId !== start.id) return;
clearTimeout(holdTimer);
if (mode === "pending") tapAt(e.clientX < innerWidth / 3 ? "prevItem" : "nextItem");
else if (mode === "swipeX") transition.release(e.clientX - start.x, velocity(e));
else if (mode === "swipeY") dismiss.release(e.clientY - start.y, velocity(e));
showOverlays(); clock.resume("hold"); start = null;
});
surface.addEventListener("pointercancel", () => {
clearTimeout(holdTimer); transition.reset(); dismiss.reset();
showOverlays(); clock.resume("hold"); start = null;
});
pointercancel must be handled like a release that does nothing: the browser fires it when it takes over the gesture (for example a system edge swipe), and without it the hold reason stays in the set forever. On iOS, a long press on an image can open the save image menu; prevent it with -webkit-touch-callout: none and user-select: none on the media, and cancel the contextmenu event on the surface.
When does a swipe commit?
Commit the user change on release if the drag passed 30% of the screen width, or if it was a fast flick (more than about 0.5 px per ms over the last 100 ms of movement), so a short quick flick works too. Otherwise animate back. Swipe down closes past 25% of the height or with a downward flick. Measure velocity from the last few pointermove samples, not from the whole gesture, or a slow start followed by a flick looks slow.
The cube or slide transition
During a horizontal drag, the current user's page and the neighbor's page move together. Instagram uses a cube: each page is a face of a box, rotated with transform: rotateY() inside a parent with perspective. A plain slide with translateX() is simpler and reads the same. Both only change transform, so the browser can composite them without layout at 60 fps.
Set will-change: transform on the two pages only while dragging, then remove it: every promoted layer costs GPU memory, which is the resource a stories viewer has least of. With prefers-reduced-motion: reduce, replace the cube with a short crossfade (about 150 ms) and keep the drag feedback off.
Think first
A keyboard user can't tap a third of the screen or hold. What are the equivalents?
Arrow Right and Arrow Left for the next and previous item, the same navigate calls a tap makes.
Space or K toggles a user pause (not hold, which belongs to the pointer). A visible pause button does the same for mouse and screen reader users.
Escape closes the viewer. Visible previous user and next user buttons on desktop replace the horizontal swipe; on phones they can be hidden from view but stay reachable for screen readers.
The swipe up action (link, reply) has a real button too. No action should exist only as a gesture.
Check your understanding · Exercise 4
Users report that holding to read a long caption sometimes skips to the next story. Which causes are likely? Select all that apply.
Pausing on its own: visibility, focus, and other dialogs
The user isn't always looking. If a story keeps running while they read a message notification or type a reply, they miss it, and it gets marked seen. Each of these situations becomes one more pause reason in the set from the timing chapter, owned by one small piece of code.
| Situation | Signal | Reason |
|---|---|---|
| Tab hidden, app switched, phone locked | visibilitychange with document.visibilityState === "hidden" | hidden |
| Desktop window loses focus (another app on top) | blur and focus on window | blur |
| Reply box or reaction picker in use | focusin and focusout on the reply form | reply |
| Another dialog opens over the viewer (share sheet, report menu) | The viewer's own open state for its sheets, or an isActive prop from the host app | modal |
User pressed pause, or play() was rejected | The pause button, Space, or a rejected promise | user |
function usePauseWhile(clock: StoryClock, reason: Reason, active: boolean) {
useEffect(() => {
if (!active) return;
clock.pause(reason);
return () => clock.resume(reason);
}, [clock, reason, active]);
}
const hidden = useDocumentHidden(); // visibilitychange
usePauseWhile(clock, "hidden", hidden);
usePauseWhile(clock, "blur", !windowFocused);
usePauseWhile(clock, "reply", replyFocused);
usePauseWhile(clock, "modal", sheetOpen || !props.isActive);
The blur reason needs care. On desktop it is right to pause when the user clicks into another app. But an iframe inside the story (say, an embedded poll) also steals window focus when clicked, so check document.activeElement before pausing, or the story freezes as soon as the user interacts with its content. On phones, blur and visibilitychange often fire together; the set makes that harmless.
Resuming after hidden should continue the same item from the same point, not skip ahead. The frame gap cap from the timing chapter does that. You could also rewind a video by a second on return so the user regains context; that is a product call, and easy because the clock reads currentTime.
Reduced motion is not a pause
prefers-reduced-motion: reduce asks for less animation, not for no auto-advance. Honor it by swapping the cube for a crossfade and removing the zoom on swipe down. Auto-advance itself is handled by the pause control that WCAG 2.2.2 requires (see the accessibility chapter). Some teams also start with auto-advance off for users with reduced motion; if you do, show a clear play control so the viewer doesn't look broken.
Check your understanding · Exercise 5
A user holds a story, and while holding, switches to another tab. They come back 3 minutes later with the finger already lifted. What does the reasons set look like, and what happens? Select all that apply.
Preloading, memory, and performance
When the user taps next, the next item should appear in under 100 ms. On a 150 ms round-trip network, fetching it after the tap can't meet that: the request alone takes longer, and a 120 KB photo or the first second of a video needs several hundred more milliseconds on slow 4G. So the viewer must fetch and decode media before it's needed. The question is how much.
Decision How far ahead should the viewer preload?
What we pick: A small sliding window, the same idea the TikTok feed guide uses for its video column. The two targets match the two likely actions: a tap goes to the next item, a swipe goes to the next user's first item. Load the current item at high priority and preloads only after it has started.
How to preload each media type
- Photos:
const img = new Image(); img.src = url; await img.decode();Thedecode()promise resolves when the image is downloaded and decoded, so swapping it in won't flash or hold up the next frame. The image optimization lesson covers picking the right size and format. - Videos, simple: a hidden pooled
videoelement withpreload="auto",muted, andplaysinline.preloadis only a hint: Chrome has treatedautoasmetadataon cellular connections, and iOS Safari often loads nothing until playback starts. Show the item'sposterimage meanwhile, as the TikTok feed guide does. - Videos, controlled: with Media Source Extensions (MSE),
fetchonly the first segment (about 1 to 2 seconds of video) with anAbortController, and append it when the item becomes current. More code, but you choose exactly how many bytes to spend. On iPhone this needsManagedMediaSource, available only since iOS 17. The video player guide covers MSE and segments. - Priority: the current item loads with
fetchpriority="high"; preloads start only after the current item's media is ready, so they never compete with what the user is looking at.
Cancelling on fast skipping
Some users skip through 10 items in 3 seconds. Each skip moves the window, and every preload that falls out should stop. MSE fetches abort with controller.abort(). A pooled video stops downloading when you call removeAttribute("src") and then load(), which also frees its buffer.
An Image request can't be reliably cancelled; at about 120 KB it is cheap enough to let it finish and drop the reference. Add a small delay (about 150 ms) before starting a video preload, so a burst of taps doesn't start and cancel ten downloads.
Keeping memory bounded
A decoded full-screen photo at 1080 by 1920 pixels takes about 8 MB (4 bytes per pixel), no matter how small the JPEG was. Thirty decoded photos would be 240 MB, enough for a phone browser to kill the tab.
So the window also decides what to release: keep the previous, current, next, and next user's first item (plus the previous user's first item if loaded), and drop references to everything else so the decoded bitmaps can be collected. Hardware video decoders are scarcer than memory on many phones, so keep a pool of 3 video elements and reuse them by changing src, never creating one per item. The memory leaks lesson shows how to confirm with a heap snapshot that the count stays flat over a long session.
Try it below. Skip fast through items and users, and watch which media the window starts, cancels, keeps decoded, and releases.
Ana's first item is on screen. Ana's second item and Ben's first item are loading.
Check your understanding · Exercise 6
The cursor is on Ana's item 2 of 4. Ben is next in the tray. Which media should be decoded or loading right now? Select all that apply.
Performance: sizes, layout, and frames
Three budgets matter for a full-screen media component: bytes per item, no layout shift when media arrives, and 60 fps during drags. The image optimization and loading strategies lessons cover the general techniques; here are the numbers for stories.
| Concern | What we do | Number to remember |
|---|---|---|
| Photo size | Serve widths 720, 1080, and 1440 in AVIF and WebP with a JPEG fallback; srcset with sizes="100vw" lets the browser pick by screen and pixel density. | A 1080 by 1920 photo is often about 100 to 150 KB as AVIF or WebP, versus 300 KB or more as JPEG. |
| Video size | Short 9:16 clips at 720p for most phones, with a low-resolution poster image shown until the first frame. | About 1 MB for a 15 s clip at roughly 500 kbps. |
| Layout shift | The media box is fixed to the viewport with aspect-ratio: 9 / 16 and object-fit: cover; bars and header are absolutely positioned. | Zero shift when an image or video arrives. |
| Opening fast | The viewer's code is split out and imported when a tray avatar gets pointerdown, before the click completes. | Saves the viewer's JavaScript from the feed's first load. |
| Frames | Swipes and bars change only transform and opacity; will-change only while dragging. | 16.7 ms per frame at 60 Hz. One large image decoded at the wrong moment can take longer than that. |
Decoding is the hidden cost. An image that isn't decoded when it's swapped in gets decoded while the browser draws the next frames, which can drop frames exactly when the user is watching motion. That's why the preloader awaits img.decode() before an item counts as ready. For videos, starting play() on the neighbor page during a swipe costs a decoder; wait until the swipe commits.
Measure in the field, not just on a fast laptop. Report time from tap to next item painted, the share of transitions that waited for loading, buffering time per video, and long tasks during swipes, through the host's analytics callback. A rising loading share is the first sign the preload window or image sizes need tuning.
Check your understanding · Exercise 7
The user-to-user swipe stutters on mid-range Android phones. Which fixes are likely to help? Select all that apply.
Video items: sound, inline playback, and captions
Video stories reuse the browser's media element; the video player guide covers that API in depth, and the TikTok feed guide covers autoplay in a feed. Four details are specific to stories.
Autoplay and sound. All major browsers allow a video to autoplay when it is muted, though Low Power Mode on iPhone can block even that. Playing with sound needs a user activation, such as a tap. Opening the viewer is a tap, so the first item can usually start with sound, but the rules differ: Chrome also weighs the user's history with the site, and Safari is stricter.
So always call play() with sound, catch the rejection, then retry muted and show an unmute button. When the user unmutes, remember it for the session so the next video starts with sound too, as the TikTok feed guide describes. If even the muted retry fails, the clock gets a user pause and the viewer shows a play button.
async function start(video: HTMLVideoElement, wantsSound: boolean) {
video.muted = !wantsSound;
try {
await video.play();
} catch (err) {
if ((err as DOMException).name !== "NotAllowedError") throw err;
video.muted = true;
showUnmuteHint();
await video.play().catch(() => clock.pause("user"));
}
}
Inline playback. On iPhones, a video without the playsinline attribute may jump into the system's full-screen player. Every pooled element needs playsinline and muted set before the first play() call.
Duration before metadata. The bar for a video needs a length before the video loads, or the segment row jumps when loadedmetadata fires. The item from the server carries durationMs, so the bar is drawn right away. Once the video is playing, the clock switches to currentTime, the real source of truth. Cap very long clips by splitting them into several items on upload, so one segment never lasts minutes.
Captions. Many people watch stories with the sound off, and deaf and hard of hearing users always do. Provide captions as a WebVTT track with kind="captions", generated by speech recognition on upload and editable by the creator. A captions toggle sits next to the mute button; its state is a user preference that persists. Captions should be drawn by the viewer, not burned into the video, so they can be resized and stay readable.
Check your understanding · Exercise 8
On iOS, the first video story sometimes opens in the system full-screen player and the progress bar stops. What should you check? Select all that apply.
Seen state, replies, and reactions
Two kinds of writes leave the viewer: seen events, which are many, small, and invisible, and replies and reactions, which are few and visible. They need different delivery designs.
Decision When does an item count as seen?
What we pick: A minimum of watched time, measured by the clock. It is honest (hidden or buffering time doesn't count), cheap, and the threshold is a prop so product can tune it. The ring logic and the viewer list on the creator's side both use the same seen events.
Batching seen events
One request per item would be 30 requests in a two-minute session. Instead, the viewer adds item ids to a local seen set (which updates the tray ring at once, an optimistic update) and to an outgoing queue. The queue flushes every 5 seconds, when it reaches 20 ids, when the viewer closes, and when the page becomes hidden.
The flush on hide must outlive the page, so the host's onSeen sends that batch with navigator.sendBeacon, or with fetch and keepalive: true. Both share a budget of about 64 KB of in-flight data per page, plenty for a list of ids. The analytics tracking lesson and the analytics SDK guide cover this pattern for events in general.
The server must treat seen as a set: marking an item seen twice changes nothing. That makes retries safe. If the user is offline, the queue persists in IndexedDB and flushes on the online event or next visit. A seen event that arrives a day late is still useful for the ring, and the server can ignore it for items that already expired.
Replies and quick reactions
Focusing the reply box adds the reply reason, so the story waits while the user types. Swiping up on mobile opens the same box. Sending is an optimistic update: the input clears, a small sent confirmation appears, and the request goes out with a client-generated id so a retry can't create a duplicate.
If it fails, the text comes back into the box with a retry button; losing a typed reply is the worst outcome. Quick reactions work the same way: tap an emoji, it animates up the screen at once, and the send happens in the background. The optimistic updates lesson covers rollbacks in depth.
Check your understanding · Exercise 9
Seen events are sometimes lost when users close the tab right after watching. Which changes fix this? Select all that apply.
URL, history, and accessibility
The viewer is a modal dialog over the page: it covers everything, it traps focus, and it closes with Escape or the back button. The modal dialog guide covers those mechanics in depth. Here we add what stories change: a URL that names a user and an item, and announcements for content that changes on its own.
Decision How should the viewer use the URL and browser history?
What we pick: Push once, replace while watching. On a direct load of /stories/ana/987, the server renders the host page (feed or profile) with the viewer open; closing then navigates to that page instead of calling history.back(), which would leave the site.
Dialog, focus, and keyboard
Use the native dialog element opened with showModal(): it makes the rest of the page inert, so focus can't escape and screen readers can't wander behind it. Give it an accessible name like "Stories from Ana".
On open, move focus to the pause button, the first control a keyboard or screen reader user needs. On close, return focus to the tray avatar that opened it. The focus management lesson covers both moves.
Announcing changes, and the pause control
Sighted users see the bars and avatar change. Screen reader users need to hear it. A visually hidden aria-live="polite" region announces each new item: "Story 2 of 5 from Ana, posted 3 hours ago", followed by the item's alt text or caption. The bars themselves are aria-hidden, since a row of 5 progress bars read aloud is noise.
Polite announcements wait until the reader finishes speaking, but a 5 s photo can end before its alt text has been read. That is one more reason the pause control matters.
WCAG 2.2.2 Pause, Stop, Hide (level A) covers content that starts on its own and is shown alongside other content. Moving content that lasts more than 5 seconds, like a video, needs a way to pause, stop, or hide it. Auto-updating content needs the same, or a way to control how often it updates, and has no 5 second exception; the W3C names auto-advancing presentations as an example. Stories are both, shown next to buttons and a reply box.
A visible pause button (a real button with aria-pressed) adds the user reason, and Space or K toggle it from the keyboard. When paused by the user, the viewer stays paused across items until they press play, so a screen reader user can move with the arrow keys at their own pace.
Alt text. Stories are pictures, often with text drawn on them. Ask creators for alt text, generate a fallback description on upload, and store any text the creator typed as data, so it can be read out instead of being lost in pixels. The visual accessibility lesson covers contrast for the overlay controls, which sit on unpredictable photos: give them a subtle dark gradient behind.
Check your understanding · Exercise 10
Which choices make the viewer usable with a screen reader? Select all that apply.
Architecture, data model, and component API
With the hard parts decided, put them in boxes. The rule for the boundaries: anything that keeps time, reads pointers, or touches media lives in its own small module with no React in it, so it can be unit tested with a fake clock and fake events. React components only render the cursor's state.
| Part | Responsibility | Why it's separate |
|---|---|---|
StoriesViewer (dialog shell) | Opens and closes the dialog, syncs the URL, owns focus, renders the current and neighbor user pages. | The only part that knows about the page around it. |
UserPage | One user's bars, header, media, and overlays. | Rendered for the current user and the two neighbors during a swipe. |
| Cursor store | The tray, entities by id, and the cursor; exposes navigate(move). | One pure reducer, tested with a table of cases. |
StoryClock | Elapsed time, pause reasons, progress callbacks, end detection. | Time logic without the DOM; fake-clock tests. |
| Gesture recognizer | Pointer events to tap, hold, and swipe intents. | Thresholds as config; testable with synthetic events. |
| Media manager | Preload window, Image decodes, the pool of 3 video elements, cancellation, release. | The one place that knows memory limits. |
| Seen queue and reply sender | Batches seen ids, persists them offline, sends optimistic replies. | Network calls go through host-provided callbacks. |
Data moves in one loop. The host page passes the tray (from its feed request) and a loader for a user's items. The cursor store asks the media manager to warm the window; the media manager fetches from the image and video CDN.
When the clock reports progress, the bar's fill is written directly; when it ends, the store navigates, and the seen queue records the item. Writes leave through the host's callbacks to the API.
The client data model
type StoryItem = {
id: string;
userId: string;
type: "image" | "video";
sources: { url: string; width: number; mime: string }[];
posterUrl?: string; // first frame for videos
durationMs: number; // 5000 for photos, clip length for videos
createdAt: string; // ISO time, for "3 hours ago"
expiresAt: string;
alt: string;
captionsUrl?: string; // WebVTT
link?: { url: string; label: string };
seen: boolean;
};
type TrayUser = { id: string; name: string; avatarUrl: string;
itemCount: number; hasUnseen: boolean; itemIds?: string[] };
type ViewerState = {
order: string[]; // tray order of user ids
users: Record<string, TrayUser>;
items: Record<string, StoryItem>;
cursor: { userId: string; itemId: string | null };
pausedBy: Set<string>; // mirrored from the clock for the UI
};
| Data | Where it lives | Why |
|---|---|---|
| Tray and items | The host's server cache, passed in as props | The feed already fetched the tray; the viewer shouldn't fetch it twice. |
| Cursor | Viewer store, mirrored to the URL | Shareable and survives a reload. |
| Elapsed time and reasons | StoryClock instance (a ref) | Changes every frame; must not trigger renders. |
| Seen set and queue | Memory plus IndexedDB | Rings update instantly; nothing is lost offline. |
| Captions preference | localStorage | A user preference across sessions. |
| Unmuted choice | Memory, for the session | A new visit has no user activation yet, so it starts muted anyway. |
Check your understanding · Exercise 11
Where should the elapsed time of the current item live? Select all that apply.
The component API
Three teams will embed this viewer (the home feed, profiles, and a highlights page), and each fetches data differently and has its own analytics. So the component does no networking of its own: it takes data and async callbacks, and reports events. The component API design lesson covers this split between controlled data and injected behavior.
type StoriesViewerProps = {
users: TrayUser[]; // tray order, from the host
initial: { userId: string; itemId?: string };
loadItems: (userId: string, signal: AbortSignal) => Promise<StoryItem[]>;
onClose: (last: { userId: string; itemId: string }) => void;
onSeen: (itemIds: string[]) => Promise<void>; // batched
onReply?: (itemId: string, text: string, clientId: string) => Promise<void>;
onReact?: (itemId: string, emoji: string, clientId: string) => Promise<void>;
onItemChange?: (e: { userId: string; itemId: string; index: number }) => void;
isActive?: boolean; // false pauses with the "modal" reason
photoDurationMs?: number; // default 5000
seenAfterMs?: number; // default 1000
tapPrevFraction?: number; // default 1/3
transition?: "cube" | "slide"; // reduced motion always crossfades
syncUrl?: boolean; // highlights page may turn it off
renderOverlay?: (item: StoryItem) => React.ReactNode; // stickers, links
};
loadItemsgets anAbortSignal, so the viewer can cancel a user's items request when the user swipes past them.onSeenandonReplyreturn promises. The viewer owns the queue, the retries, and the optimistic UI; the host only owns the transport.clientIdis generated by the viewer and makes replies and reactions idempotent on the server.renderOverlayis the extension point for stickers and polls, so the core doesn't grow a feature per product request.
The server calls behind the callbacks
GET /api/stories/tray?cursor=abc -> { users: TrayUser[], nextCursor }
GET /api/stories/users/ana/items -> { items: StoryItem[] }
POST /api/stories/seen
{ "itemIds": ["987", "988", "990"] } -> 204
POST /api/stories/987/replies
{ "clientId": "c-41f", "text": "Love this" } -> 201 { "id": "r-55" }
-> 409 if clientId was already used (treat as success)
POST /api/stories/987/reactions
{ "clientId": "c-420", "emoji": "fire" } -> 201
Item responses carry short-lived signed media URLs that point to the CDN, already expired items are filtered out, and each sources entry lists widths and formats so the client picks the right one. If a URL has expired by the time the user reaches it (a viewer left open for an hour), the media error event triggers one reload of that user's items before showing an error state.
Check your understanding · Exercise 12
A product team asks for the viewer to call their analytics service on every item view. What's the best way to support that? Select all that apply.
Running it in the interview
A stories viewer fits a 45 minute component round well because it has a clear core (the clock) and many directions to go deep. Plan the time so the core gets the most.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 5 | Ask the scope questions: media types, durations, devices, who embeds it. | A list of assumptions in a corner. |
| 5 to 10 | Name the five hard parts. Sketch the screen. | The wireframe: bars, header, media, tap zones, reply bar. |
| 10 to 22 | The model and the clock: cursor of ids, navigation edges, pause reasons, video as a clock source. | The clock state machine. |
| 22 to 30 | Gestures: thresholds, pause on down, swipe commit rules. | The tap sequence. |
| 30 to 38 | Preloading window and memory; seen rule and batching. | The preload window. |
| 38 to 45 | Component API, accessibility, and follow-ups. | The props list. |
Say the key sentence early: the viewer runs on one clock that only advances when a set of pause reasons is empty. Interviewers often test it straight away with a combination, like a hold during buffering, and the set answers it in one line.
Follow-up questions to expect
- How do you test the clock? Inject the time source. Tests call
tick(now)with fake timestamps and assert progress, pause, and end, without real waiting. - What if a story fails to load? After a timeout (about 8 s) or an
errorevent, show a retry state in place of the media, keep the clock paused forloading, and let taps still navigate. - How would you add live stories? A live item has no duration; the bar becomes an indicator, the clock source is the live stream, and the next tap leaves it. The video player guide covers the streaming side.
- How do you stop the tray from going stale? Refresh it when the viewer closes and on focus after a few minutes, and merge by user id so the cursor never breaks.
Check your understanding · Exercise 13
The interviewer asks how you'd make sure the clock behaves correctly. Which answers are strong? Select all that apply.
Summary
- Model the tray as users and items by id, with a cursor of ids; one pure
navigatefunction handles every edge for taps, keys, swipes, and item ends. - One
requestAnimationFrameclock accumulates watched time only while a set of pause reasons is empty; videos usecurrentTimeas the clock, and frame gaps are capped. - Every pause source (hold, hidden, blur, reply, modal, buffering, loading, user) owns one reason, so combinations can never resume early or stay stuck.
- Recognize gestures with Pointer Events: pause on down, tap under 250 ms and 10 px, hold after that, swipe by direction, commit by distance or velocity, and always handle
pointercancel. - Preload a small window (next item and the next user's first item), cancel what falls out, keep about 5 decoded items and a pool of 3 video elements.
- Count an item as seen after a minimum of watched time, update rings optimistically, and send seen ids in idempotent batches that survive closing and going offline.
- Treat the viewer as a modal dialog: push one history entry, replace while watching, back closes; announce each item politely and give a real pause button for WCAG 2.2.2.
- Ship it as a component with data in and callbacks out, so each host owns fetching and analytics while the viewer owns time, input, and media.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- MDN: Window.requestAnimationFrame() The frame callback, its timestamp, and that it pauses in background tabs.
- MDN: Pointer events One event model for touch, mouse, and pen, including pointer capture and cancel.
- MDN: Page Visibility API
visibilitychangeand why it's the reliable signal to pause and flush. - MDN: HTMLImageElement.decode() Decode an image before inserting it, so swaps don't flash or delay a frame.
- Chrome for Developers: Autoplay policy in Chrome When muted and unmuted autoplay are allowed.
- WebKit: New video policies for iOS Muted autoplay and the
playsinlineattribute on iPhone. - W3C: Understanding WCAG 2.2 Success Criterion 2.2.2 Pause, Stop, Hide Why auto-advancing content needs a pause control.
- MDN: The dialog element
showModal(), inert background, and focus behavior. - MDN: Navigator.sendBeacon() Sending small batches reliably when the page is hidden or closing.
- web.dev: Fast playback with audio and video preload Why
preloadis only a hint, and the alternatives: link preload and manual buffering with MSE. - Chrome for Developers: Heavy throttling of chained JS timers in Chrome 88 How background tabs slow timers, and why a story clock shouldn't count on them.
- Hammer.js: Press recognizer Typical tap and long-press thresholds in a gesture library.
Concepts behind it
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Prefetching and preloading dataStarting the fetch for the data and code of the next screen before the user asks for it, on hover, on sight, or on a guess, so the click feels instant without wasting the user's data plan.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Product analyticsRecording what users do as well-named events that follow a tracking plan, delivered reliably even offline, with consent respected, and checked so that the numbers people decide with are true.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.