cf.completefrontend
My progress
SYSTEM DESIGN · #42

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.

QuestionAnswer we assumeWhat 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.
Wireframe of the stories viewer on a phone. 1: a row of four thin progress bars at the top, the first full, the second half full, the rest empty. 2: a header with the avatar, the name Ana, and 3h ago. 3: pause, mute, and close buttons on the right of the header. 4: the left third of the media is the previous zone. 5: the right two thirds is the next zone. 6: a reply box with quick reactions at the bottom. Notes explain each part, and that holding anywhere pauses and swiping changes user, closes, or opens replies.Stories2 Ana, 3h ago3 II M X4 prev5 next6 reply, reactions1 one bar per itempast full, current filling2 header: avatar, name, age3 pause, mute, close buttons4 left third: previous item5 right two thirds: next itemhold anywhere: pauseswipe: user, close, or reply6 reply box and reactionsfocus here pauses the story
The media fills the screen and is split into two invisible tap zones. Everything else is an overlay that hides while the user holds, so they can look at the picture.

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

QualityTargetWhy this number
Next item appearsUnder 100 ms after a tap when preloadedAbout 100 ms is where a response stops feeling instant. A visible spinner between photos breaks the flow.
Clock accuracyA 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.
Transitions60 frames per second for the user-to-user swipe on a mid-range phoneA dropped frame during a drag feels like the screen is stuck to the finger.
MemoryAt most about 5 decoded media items and 3 video elements alive at onceLow-end phones kill tabs that hold too many decoded images or video decoders.
DataPreload at most 2 items aheadMany people watch only a few stories; each wasted video is 0.5 to 2 MB of their data plan.
AccessibilityWCAG 2.2 AA, including 2.2.2 Pause, Stop, HideContent 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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?

The model: a tray, a cursor, and navigation rules

Before timing or gestures, decide what the viewer is looking at. The data is two-level: a tray is an ordered list of users, and each user has an ordered list of items. Each item is a photo or a video with a duration, a created time, and a seen flag. Where the user is in all this is a cursor: which user, which item.

Decision How should the viewer represent where the user is?

Flatten into one list

Concatenate every user's items into a single array and keep one index.

Next and previous are index plus or minus one.

Pros

  • Trivial next and previous.
  • One number to store.

Cons

  • Needs every user's items loaded up front, or the indexes shift when a user's items arrive.
  • Swiping to the next user means searching for the next user boundary.
  • Progress bars are per user, so you must recompute the boundaries anyway.

Nested arrays with two indexes

Keep users[], each with items[], and a cursor of { userIndex, itemIndex }.

Pros

  • Matches the screen: bars belong to one user.
  • Users can load their items lazily.

Cons

  • Indexes break when the tray is refreshed and a user is added or removed in front of you.

Ids plus ordered id lists

Store trayOrder: userId[] and per user itemIds[], entities in maps by id.

The cursor is { userId, itemId }. Index is derived when needed.

Pros

  • Survives tray refreshes and late-loading items.
  • The cursor maps directly to a URL like /stories/ana/987.
  • Seen state and replies update one entity by id.

Cons

  • A lookup to find the index for rendering bars (cheap: at most 30 items).

What we pick: Ids plus ordered id lists. The cursor stays valid when data changes under it, and it is exactly what the URL needs. Indexes are derived for rendering only.

Write the rules down as a pure function: given the tray and a cursor, return the next cursor, or a signal to close. Pure functions are easy to unit test with a table of cases, and every input (tap, key, swipe, end of a video) calls the same function.

ts
type Cursor = { userId: string; itemId: string };
type Move = "nextItem" | "prevItem" | "nextUser" | "prevUser";

function navigate(t: Tray, c: Cursor, move: Move): Cursor | "close" {
  const u = t.order.indexOf(c.userId);
  const items = t.users[c.userId].itemIds;
  const i = items.indexOf(c.itemId);
  if (move === "nextItem" && i < items.length - 1)
    return { userId: c.userId, itemId: items[i + 1] };
  if (move === "prevItem" && i > 0)
    return { userId: c.userId, itemId: items[i - 1] };
  if (move === "prevItem" && u === 0) return c; // restart first item
  if (move === "nextItem" || move === "nextUser") {
    const next = t.order[u + 1];
    if (!next) return "close";
    return { userId: next, itemId: firstUnseenOrFirst(t, next) };
  }
  const prev = t.order[u - 1];
  if (!prev) return c;
  return { userId: prev, itemId: move === "prevItem" ? lastOf(t, prev) : firstOf(t, prev) };
}
One pure function decides every move; inputs only choose the direction.
SituationRuleWhy
Next at the last itemGo to the next user, at their first unseen itemUsers expect to continue where they left off with that person.
Next at the last item of the last userClose the viewerThere is nothing left; returning to the feed is the natural end.
Previous at the first itemGo to the previous user's last itemFeels like rewinding one step, not jumping a whole user.
Previous at the very first itemRestart the current item from 0Many viewers do this; it gives the tap a visible effect instead of doing nothing.
Swipe to the next userNext user, first unseen itemA swipe means skip this person entirely.
Swipe to the previous userPrevious user, first itemGoing back to a person replays them from the start.
All items of the next user are seenStart at their first itemA fully seen user replays from the top.
Navigation across a tray of three users. Ana has items a1, a2, a3; the cursor is on a2. Ben has b1 and b2. Cara has c1. Next at a3 goes to Ben's b1, and previous at b1 goes back to Ana's last item a3. A swipe from a2 jumps straight to Ben's b1. Next at Cara's c1, the last item of the last user, closes the viewer. Notes: the next user starts at their first unseen item, and previous at the very first item restarts it.AnaBenCaraa1a2a3b1b2c1closenextprevswipe: next usernext user: first unseenvery first item: restart
Every input calls the same navigate function, so a tap at a3 and a swipe from a2 can land on the same item through different rules. The edges are where bugs hide; test each one.

Items of a user who hasn't been opened yet may not be loaded. The tray response carries, per user, the item count and whether any item is unseen, which is enough to draw the ring and the right number of bars.

The items themselves come from a per-user request when the user becomes current or next. If the cursor moves to a user whose items aren't loaded, the cursor holds { userId, itemId: null } and the viewer shows the avatar and empty bars while the request runs, with the clock paused for a loading reason (see the timing chapter).

Check your understanding · Exercise 2

The user is on Ben's first item. Ana is before Ben in the tray, and Ana has 3 items. The user taps the left third. What should happen? Select all that apply.

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?

setTimeout plus a CSS transition

Start a 5 s setTimeout to advance, and a 5 s transition: transform on the bar.

On pause, clear the timer, read the bar's computed transform, and restart both with the remaining time.

Pros

  • Very little JavaScript per frame.
  • The bar animates on the compositor.

Cons

  • Two clocks that must be kept in sync by hand.
  • Reading the computed transform on every pause is fiddly and forces style work.
  • Doesn't help with videos at all.

CSS animation with animation-play-state

The bar runs @keyframes fill for the item's duration; animationend advances.

Pausing sets animation-play-state: paused.

Pros

  • Pause and resume are exact and free.
  • Runs on the compositor.

Cons

  • Timing lives in CSS, so the app can't easily read elapsed time for analytics or the seen rule.
  • A global reduced-motion rule that shortens animations, or display: none, changes or stops the animation and its event.
  • Still a separate path for videos.

requestAnimationFrame with an accumulated elapsed time

One loop adds the time since the last frame to elapsed only while nothing is pausing it.

Progress is elapsed / duration; at 1 it advances.

For videos, the same loop reads video.currentTime / video.duration instead.

Pros

  • One source of truth for photos and videos.
  • Pause and resume are exact because nothing is added while paused.
  • elapsed is a plain number for analytics, seen rules, and tests with a fake clock.

Cons

  • A small amount of JavaScript every frame (cheap if it only writes one transform).
  • Must cap large frame gaps so a tab coming back doesn't jump ahead.

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.

ts
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);
  };
}
The clock adds frame time only when no reason is active, and caps gaps at 100 ms.

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.

State machine of one story item's clock. loading goes to running when the media is ready and no pause reason is set. running goes to ended when progress reaches 1, which navigates to the next item. running goes to paused when any reason is added, and paused goes back to running only when the reason set is empty. loading goes to error when the media fails or takes over 8 seconds, and error goes back to loading on retry. A note lists the reasons: hold, hidden, blur, reply, modal, buffering, loading, user.loadingrunningendederrorpausedreadyp = 1navigate(next)add reasonset emptyfailsretryhold, hidden, blur,reply, modal, user,buffering, loading
There is no single paused flag: paused means the set is not empty. Elapsed time only grows in running, so leaving and coming back to running continues from the same point.

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.

Four progress bars. Item 1, a photo of 5 seconds, is 0 percent done. The clock is running. Active pause reasons: none.photo 5 svideo 8 sphoto 5 sphoto 5 sholdhiddenreplybufferinguserelapsed 0.00 s of 5 sclock: runningreasons: nonephoto: frame time adds up
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.

GestureHow it's recognizedAction
TapUp within 250 ms and moved less than 10 pxLeft third: previous item. Right two thirds: next item.
HoldStill down after 250 ms, moved less than 10 pxPause while held, hide the overlays; resume on release.
Horizontal swipeMoved 10 px or more, mostly sidewaysDrag the user transition; commit to next or previous user on release.
Swipe downMoved 10 px or more, mostly downwardDrag the viewer down and shrink it; close on release past the threshold.
Swipe upMoved 10 px or more, mostly upwardOpen 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?

Click handlers on two invisible halves

Two absolutely positioned buttons, left and right, with onClick.

Pros

  • Accessible by default; trivial.

Cons

  • A click fires after a long press too, so a hold also skips.
  • No swipes, no drag feedback.
  • Mobile browsers may delay or cancel the click when the finger moved slightly.

Touch events plus mouse events

touchstart, touchmove, touchend on phones, and mouse events on desktop.

Pros

  • Works everywhere touch exists.

Cons

  • Two code paths, and the browser also fires emulated mouse events after touches, so you must dedupe.
  • Pen input falls through.

Pointer Events with a recognizer

pointerdown, pointermove, pointerup, pointercancel on the surface.

setPointerCapture keeps events coming even if the finger leaves the element.

touch-action: none stops the browser from scrolling or zooming during a drag.

Pros

  • One code path for touch, mouse, and pen.
  • Full control of thresholds and drag feedback.

Cons

  • We own the recognizer and its edge cases (multi-touch, cancel).

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.

ts
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;
});
The recognizer: pause immediately, classify by distance and time.

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.

Sequence of a tap to the next item. The user presses down; the recognizer adds the hold reason to the clock. The user lifts after 120 ms, so the recognizer calls navigate next on the store. The store asks the media manager to show the next item, and the media manager replies that it is already decoded. The store starts a new 5 second clock, and the clock's frames fill the bar the user sees.UserRecognizerClockStoreMediapointerdownpause(hold)up, 120 msnavigate(next)show nextdecodedstart 5 sbar fills
The pause starts at the touch, before the recognizer knows it's a tap. Because the next item was preloaded and decoded, the swap happens in the same frame, with no loading state.

Think first

A keyboard user can't tap a third of the screen or hold. What are the equivalents?

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.

SituationSignalReason
Tab hidden, app switched, phone lockedvisibilitychange with document.visibilityState === "hidden"hidden
Desktop window loses focus (another app on top)blur and focus on windowblur
Reply box or reaction picker in usefocusin and focusout on the reply formreply
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 appmodal
User pressed pause, or play() was rejectedThe pause button, Space, or a rejected promiseuser
ts
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);
Each source owns one reason; none of them knows about the others.

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?

No preloading

Load each item when the cursor reaches it.

Pros

  • Zero waste.
  • Nothing to cancel or clean up.

Cons

  • A spinner between every item on mobile networks.
  • The clock spends most of its time paused for loading.

All items of the current user

When a user opens, fetch all of their items in parallel.

Pros

  • Tapping through one user is instant.

Cons

  • A user with 20 video items means 20 MB before the first tap.
  • Parallel downloads slow down the item on screen.
  • Swiping to the next user still waits.

A small sliding window

Preload the next item of this user and the first item of the next user.

Keep the previous item decoded for a quick back tap.

Recompute the window on every cursor move; cancel what falls out.

Pros

  • Both likely next moves (tap, swipe) are instant.
  • Waste is bounded to about two items when the user closes.
  • Memory stays at about five items.

Cons

  • A tap during a fast skip can still outrun the preloader.
  • Cancelling needs per-media-type care.

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.

The preload window. Ana has four items; a1 is kept decoded for a back tap, a2 is on screen, a3 is loading as the next item, and a4 is not loaded. Ben's first item b1 is loading for a swipe; b2 is not loaded. Cara's c1 is not loaded. Notes: decoded items are a1, a2, a3, and b1, about 8 MB each at full screen; anything leaving the window is released, and videos come from a pool of 3 elements.AnaBenCaraa1a2a3a4b1b2c1keptnowtapswipedecoded: a1, a2, a3, b1about 8 MB each, full screenleaves the window: releasedvideos: a pool of 3 elements
Two targets for the two likely moves: a tap lands on a3, a swipe lands on b1. Everything two steps away waits, because most sessions never get there.

How to preload each media type

  • Photos: const img = new Image(); img.src = url; await img.decode(); The decode() 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 video element with preload="auto", muted, and playsinline. preload is only a hint: Chrome has treated auto as metadata on cellular connections, and iOS Safari often loads nothing until playback starts. Show the item's poster image meanwhile, as the TikTok feed guide does.
  • Videos, controlled: with Media Source Extensions (MSE), fetch only the first segment (about 1 to 2 seconds of video) with an AbortController, and append it when the item becomes current. More code, but you choose exactly how many bytes to spend. On iPhone this needs ManagedMediaSource, 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.

Four users and their items. a1 is on screen. Loading or decoded: a1, a2, b1. Downloaded 0.72 MB, of which 0.00 MB was wasted. 0 items shown instantly, 1 waited.Anaa1pa2va3pa4pBenb1vb2pb3pCarac1pc2vDand1vd2pd3pdownloaded: 0.72 MBwasted: 0.00 MBinstant: 0, waited: 1in memory: 3 itemsvideo elements: 2 of 3window: next, next userp photo, v video; red: paid for, never shown
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.

ConcernWhat we doNumber to remember
Photo sizeServe 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 sizeShort 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 shiftThe 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 fastThe 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.
FramesSwipes 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.

ts
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"));
  }
}
Try with sound, fall back to muted, and never leave the clock stuck.

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?

As soon as it's displayed

Mark seen when the cursor lands on the item.

Pros

  • Simple; matches what the creator expects from a viewer list.

Cons

  • Preload bugs or a cursor that passes through items during a fast skip can mark items nobody saw.
  • Items shown while the tab was hidden count.

After a minimum of watched time

Mark seen when the clock has run (no pause reasons) for a minimum, say 1 s, or when the item ends if it's shorter.

Pros

  • Only counts what was really on screen and unpaused.
  • Uses the same clock, so the rule is a single comparison.

Cons

  • A very fast tap-through doesn't count, which is a product decision to agree on.

Only when it plays to the end

Mark seen on the item's end.

Pros

  • Strong signal of attention.

Cons

  • Tapping forward through a story you've read leaves it unseen, so the ring stays colored and the user sees it again. Users find that broken.

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.

Sequence of a reply that fails. The user focuses the reply box, which adds the reply reason to the clock. The user sends; the reply box hands the text and client id c-41f to the sender and at once shows Sent to the user. The sender posts the reply; the API answers 503. The sender gives the text back to the reply box with a retry button, and the user retries with the same client id.UserReplyBoxClockSenderAPIfocuspause(reply)sendsend, c-41fSent shownPOST reply503restore textretry
The story stays paused the whole time because the reply reason is set while the box has focus. On failure, the typed text comes back; the retry reuses the client id, so a request that did reach the server can't post twice.

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?

No URL at all

The viewer is pure component state.

Pros

  • Nothing to sync.

Cons

  • Back on Android leaves the whole page instead of closing the viewer.
  • No way to share or deep link a story.

Push one history entry per item

history.pushState on every item change.

Pros

  • Back steps through stories.

Cons

  • After 30 items, the user needs 30 back presses to leave. History becomes a trap.

Push once on open, replace while watching

Open pushes /stories/ana/987.

Item and user changes call history.replaceState with the new ids.

popstate (back) closes the viewer; closing with the X calls history.back().

Pros

  • Back always closes, in one press.
  • The URL is always shareable and reloadable at the current item.

Cons

  • Must handle a direct page load, where there is no entry below to go back to.

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.

PartResponsibilityWhy 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.
UserPageOne user's bars, header, media, and overlays.Rendered for the current user and the two neighbors during a swipe.
Cursor storeThe tray, entities by id, and the cursor; exposes navigate(move).One pure reducer, tested with a table of cases.
StoryClockElapsed time, pause reasons, progress callbacks, end detection.Time logic without the DOM; fake-clock tests.
Gesture recognizerPointer events to tap, hold, and swipe intents.Thresholds as config; testable with synthetic events.
Media managerPreload window, Image decodes, the pool of 3 video elements, cancellation, release.The one place that knows memory limits.
Seen queue and reply senderBatches seen ids, persists them offline, sends optimistic replies.Network calls go through host-provided callbacks.
Architecture of the stories viewer. Inside the StoriesViewer component: Gestures for tap, hold, and swipe; StoryClock with pause reasons; Dialog shell for URL, focus, and close; Cursor store with the tray and cursor; UserPage for bars, header, and media; Media manager with the preload window and video pool; Live region that announces items; Write queue for seen ids and replies. The user's pointer goes to Gestures, which pauses the clock and navigates the store. The clock reports the end of an item to the store. The store drives the dialog shell, renders the UserPage, and warms the media manager. Outside: the host app passes data and callbacks to the store, the media manager fetches from the CDN, and the write queue sends to the stories API through the host.userStoriesViewerGesturestap, hold, swipeStoryClockpause reasonsDialog shellURL, focus, closeCursor storetray, cursorUserPagebars, header, mediaMedia managerwindow, video poolLive regionannounces itemsWrite queueseen, repliesnavigateendrenderwarmHost appdata, callbacksMedia CDNimages, videoStories APIvia the host
Time, input, and media each live in a module with no React in it, so each can be tested with fake clocks and fake events. The store is the hub; the live region and the write queue listen to its cursor changes and the clock's watched time.

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.

Data flow for the viewer. The stories API sends items with signed URLs as JSON to the host app's server cache. The host passes users and loadItems as props to the cursor store, which passes the cursor to the UserPage. The store warms the media manager, which downloads media bytes from the image and video CDN and hands decoded media to the UserPage. The StoryClock drives the bar fill on the UserPage and reports seen items after 1 second to the write queue, which sends a batch through the host's onSeen callback, and the host posts seen ids to the API.Stories APIsigned URLsHost appserver cacheStorecursorUserPagerenderJSONpropscursorPOST seenMedia CDNimages, videoMediawindowmedia byteswarmdecodedWrite queueseen idsStoryClockwatched msbar fillonSeen batchseen after 1 s
Reads come in two streams: small JSON through the host, large media straight from the CDN. Writes go back in batches through the host, so the viewer never holds an auth token or an API URL.

The client data model

ts
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
};
Entities by id, order as id lists, and a cursor of ids.
The client data model. ViewerState holds the tray order and the cursor, and points to TrayUser entities by id. Each TrayUser has an id, name, avatar, item count, unseen flag, and item ids once loaded, which point to StoryItem entities. Each StoryItem has sources by width, alt text, a captions URL, a link, created time, and a seen flag. Kept outside the store: the URL holds the cursor as /stories/ana/987, IndexedDB holds the seen queue, and a ref holds the StoryClock's elapsed time and reasons.ViewerStateorder, cursorTrayUserby idStoryItemby idorder: ana, bencursor: ana, 987name, avatarhasUnseenitemIdssources, altcaptionsUrl, linkcreatedAt, seenkept outside the storeURL/stories/ana/987IndexedDBseen queueClock refelapsed, reasons
Entities live in maps by id, order lives in id lists, and the cursor is a pair of ids. Anything that changes every frame stays in a ref, outside the store.
DataWhere it livesWhy
Tray and itemsThe host's server cache, passed in as propsThe feed already fetched the tray; the viewer shouldn't fetch it twice.
CursorViewer store, mirrored to the URLShareable and survives a reload.
Elapsed time and reasonsStoryClock instance (a ref)Changes every frame; must not trigger renders.
Seen set and queueMemory plus IndexedDBRings update instantly; nothing is lost offline.
Captions preferencelocalStorageA user preference across sessions.
Unmuted choiceMemory, for the sessionA 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.

ts
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
};
Data in, callbacks out; behavior knobs have safe defaults.
  • loadItems gets an AbortSignal, so the viewer can cancel a user's items request when the user swipes past them.
  • onSeen and onReply return promises. The viewer owns the queue, the retries, and the optimistic UI; the host only owns the transport.
  • clientId is generated by the viewer and makes replies and reactions idempotent on the server.
  • renderOverlay is the extension point for stickers and polls, so the core doesn't grow a feature per product request.

The server calls behind the callbacks

http
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
Items are loaded per user; seen is a batched, idempotent set write.

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.

MinutesWhat to doWhat to draw
0 to 5Ask the scope questions: media types, durations, devices, who embeds it.A list of assumptions in a corner.
5 to 10Name the five hard parts. Sketch the screen.The wireframe: bars, header, media, tap zones, reply bar.
10 to 22The model and the clock: cursor of ids, navigation edges, pause reasons, video as a clock source.The clock state machine.
22 to 30Gestures: thresholds, pause on down, swipe commit rules.The tap sequence.
30 to 38Preloading window and memory; seen rule and batching.The preload window.
38 to 45Component 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 error event, show a retry state in place of the media, keep the clock paused for loading, 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 navigate function handles every edge for taps, keys, swipes, and item ends.
  • One requestAnimationFrame clock accumulates watched time only while a set of pause reasons is empty; videos use currentTime as 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next