Design Lounge
Install

Nº 315 of 520

Mobile scroll story

A six-chapter riso brand story for phones: full-height snapped chapters, a tappable segmented progress bar, big overprinted numbers and drawings that animate in.

Preview

Open full size
390 × 844 · 14.8 KB · 252 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Mobile scroll story" (piece mobile-scroll-story) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

294 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

A phone web page that tells a brand story in six full-height chapters, like a printed zine you scroll. The brand is Ferry Rye, a bakery on Skerry Island whose loaves cross to the mainland on the 7:40 Saturday ferry. Each chapter has a 128px chapter number in deep blue with a fluoro pink copy printed 5px off, a small two-colour drawing, a condensed headline and a short serif paragraph. A thin bar of six segments at the top shows where you are and lets you tap to jump. Chapters snap with scroll-snap-type: y proximity, so the page settles on a chapter but never fights a slow read. The last chapter is the call to action. The detail worth copying is the print look done with mix-blend-mode: multiply: pink over blue turns a deep violet, exactly like two riso drums.

This is not mobile-story-viewer. That is a timed, tap-through story with a 5s clock. Here the reader scrolls a normal page and the bar only reports position.

Reference behaviour

  1. Initial state, 390×844: chapter 1 fills the screen. The bar shows segment 1 full in pink, segments 2 to 6 empty grey. Under the bar: “Ferry Rye · Skerry Island” left and “01 / 06” right. “01” with its pink offset sits top left, “A story in six crossings” top right. A blue loaf with a pink overprint and three score marks sits in the middle. The headline reads “How a Ferry Rye loaf gets to you”, then one paragraph, then a “Scroll” hint with an arrow nudging 4px every 1.6s.
  2. Scrolling 40px fades the hint out.
  3. As chapter i enters from the bottom, segment i fills left to right with scaleX, from 0 when the chapter’s top meets the bottom of the screen to 1 when its top meets the top of the screen.
  4. The current chapter is the last one whose top is above the middle of the screen. Its segment turns pink (multiplied over the blue, so it reads violet when full). Past segments stay blue. The label updates to “02 / 06” and so on.
  5. When a chapter is 45% visible it gets the class in. Its number, drawing, headline, paragraph and extras fade up 28px over 600ms, 0, 80, 160, 220 and 300ms apart. The pink copy of the number slides from 18px, 12px off to 5px, 4px off over 900ms: the plates come into register.
  6. Each drawing has its own motion, which only runs while its chapter is in:
    • 01 loaf: rises from 60% height to full, 900ms.
    • 02 starter jar: three bubbles rise 80px and fade, 3.2s loop, 1.1s apart.
    • 03 mill: the wheel turns once every 18s.
    • 04 fold: three layers fold down from −80°, 700ms each, 250ms apart.
    • 05 oven: three heat lines rise and fade, 2.4s loop, 0.8s apart.
    • 06 ferry: the boat rocks ±3° over 3.6s, the waves slide 60px every 5s.
  7. When a chapter drops under 5% visible it loses in, so it plays again next time.
  8. Tapping a segment smooth-scrolls to that chapter’s top and moves focus to its headline without a second scroll.
  9. Chapter 6 ends with a full-width blue button “Order a loaf · £6.50” with a hard pink offset shadow, and a text link “See the four pickup points”.
  10. Every chapter leaves 84px plus 20px at the bottom for the browser bar that the Lounge draws.
  11. With reduced motion: all content is visible at once, no loops run, pink is in register, and segment taps jump without smooth scrolling.

Structure

390 × 844
┌──────────────────────────────┐
│ (54px clearance)             │
│ ▬▬▬▬ ▬▬▬▬ ▬▬▬▬ ▬▬▬▬ ▬▬▬▬ ▬▬▬▬ │ 6 buttons, 40px tall, 4px bar at 18px
│ FERRY RYE · SKERRY ISL  02/06│ 13px Anton
├──────────────────────────────┤ fixed header ends ~118px
│ 02               CHAPTER TWO │ number 128px, kicker 14px
│                  THE STARTER │
│                              │
│          ┌──────┐            │ drawing, flex 1, max 240px wide
│          │ jar  │            │
│          └──────┘            │
│                              │
│ AGNES LIVES IN A JAR         │ 44px Anton
│ Our starter is forty-one …   │ 17px serif, max 34ch
│                              │
│ (84px + 20px for browser bar)│
└──────────────────────────────┘
6 × section, min-height 100vh, snap start
  • header.chrome is fixed to the top, opaque --paper, padding max(54px, safe-area-top) 16px 6px.
  • Inside it, a nav labelled “Chapters” holds an ol of six buttons, built from the chapters. Under it, an aria-hidden label row.
  • main holds six section.ch, each labelled by its h2. Each h2 has tabindex="-1" so a segment tap can focus it.
  • Each section is a flex column: .head (number and kicker), .vis (drawing, flex 1), h2, p, and any extras.
  • The number is a p with aria-hidden and data-n. The pink copy is its ::after with content: attr(data-n).
  • Drawings are inline SVG, viewBox 240×180, aria-hidden.

Tokens

:root {
  /* colour */
  --paper: #f3ecdc;     /* cream stock */
  --blue: #2338a0;      /* drum 1: type, drawings, button */
  --pink: #ff48b0;      /* drum 2: overprint, current segment, shadow */
  --ink: #1b2350;       /* body text */
  --track: #d6d3d6;     /* empty segment */
  --grain: rgba(35, 56, 160, .07);
  --focus: #ff48b0;

  /* type */
  --display: "Anton", Impact, sans-serif;
  --serif: "Source Serif 4", Georgia, serif;
  --size-num: 128px;
  --size-h2: 44px;
  --size-body: 17px;
  --size-kick: 14px;
  --size-label: 13px;
  --size-cta: 20px;

  /* layout */
  --top: max(54px, env(safe-area-inset-top));
  --bottom: 84px;           /* browser bar drawn by the Lounge */
  --pad-x: 20px;
  --seg-h: 40px;            /* hit target */
  --seg-bar: 4px;
  --seg-gap: 4px;
  --misreg: 5px 4px;        /* pink offset at rest */

  /* motion */
  --ease: cubic-bezier(.16, 1, .3, 1);
  --std: cubic-bezier(.2, .7, .2, 1);
  --dur-enter: 600ms;
  --dur-register: 900ms;
  --enter-shift: 28px;
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase / colour
Chapter numberAnton128px (96px under 780px tall)4000.82-0.02em--blue, pink copy
HeadlineAnton44px (36px under 780px tall)4000.980.005emUpper, --blue
BodySource Serif 417px4001.50--ink, max 34ch
KickerAnton14px4001.250.08emUpper, right aligned, --blue
Bar labelAnton13px40010.08emUpper, tabular numbers
HintAnton13px40010.1emUpper
ButtonAnton20px40010.04emUpper, --paper on --blue
Text linkSource Serif 417px6001.50Underlined, 4px offset

Anton is the condensed display face. Never set the paragraph in it. Source Serif 4 at 17px stays readable at 360 wide.

Motion

ThingTriggerPropertyFrom → toDuration / easing / delayReduced motion
Segment fillscrolltransform scaleX0 → 1Scrubbed per frameSame, it is position
Current segmentchapter crosses mid screenbackgroundblue → pink multiplyInstantSame
Numberchapter inopacity, translateY0, 28px → 1, 0600ms expo, 0msVisible
Pink platechapter intransform translate18px 12px → 5px 4px900ms expo, 150msAt 5px 4px
Drawingchapter inopacity, scale0, 0.92 → 1, 1600ms expo, 80msVisible
Headlinechapter inopacity, translateY0, 28px → 1, 0600ms expo, 160msVisible
Paragraphchapter inopacity, translateY0, 28px → 1, 0600ms expo, 220msVisible
Hint, button, linkchapter inopacity, translateY0, 28px → 1, 0600ms expo, 300msVisible
Loafchapter inscaleY, origin bottom0.6 → 1900ms expo, 200msFull
Bubbleswhile intranslateY, opacity0 → −80px, fade3.2s loop, 1.1s apartStill, visible
Mill wheelwhile inrotate0 → 360°18s linear loopStill
Fold layerschapter inrotate, origin bottom left−80° → 0700ms expo, 250ms apartFlat
Heat lineswhile intranslateY, opacity10px → −26px2.4s loop, 0.8s apartHidden motion, lines still
Boatwhile inrotate, translateY−3° → 3°, −5px3.6s ease-in-out alternateStill
Waveswhile intranslateX0 → −60px5s linear loopStill
Hint arrowalwaystranslateY0 → 4px → 01.6s standardStill
Button pressactivetranslate, shadow0 → 3px 3px, shadow 5/4 → 2/1160ms standardSame
Segment tapclickpage scrollto chapter topSmoothInstant

Loops use animation-play-state: paused until the chapter is in. Off-screen chapters cost nothing.

States

  • Segment empty: 4px --track bar. Filling: blue scaleX(f). Current: pink with multiply. Past: full blue.
  • Segment button: 40px tall, full flex width. The bar is drawn at 18px from its top.
  • Chapter not in: content at opacity 0, 28px low. Chapter in: visible, loops running.
  • Hint: visible at scroll 0 to 40px.
  • Button resting: blue, pink 5px 4px shadow. Active: moves 3px down and right, shadow 2px 1px.
  • Focus-visible: 2px pink outline, 2px offset, on segments, button and link. Headlines focused by script show no ring, since focus moved there by a tap.
  • No JS: the js class is absent, so every chapter is fully visible. The bar is empty (it is built by script).

Accessibility

  • The bar is a nav labelled “Chapters” with six real buttons. Each has an aria-label like “Chapter 3 of 6: The mill”.
  • The current segment has aria-current="step". Only one at a time.
  • A segment tap moves focus to the chapter’s h2 with preventScroll: true, so screen reader users land in the right place.
  • Each chapter is a section with aria-labelledby its headline.
  • Big numbers and drawings are aria-hidden. The headline already names the chapter.
  • The label row “02 / 06” is aria-hidden and not live. Announcing every chapter change during scroll is noise.
  • Hit targets: segments are 40px tall and about 57px wide at 390. The button is 56px tall. The text link is 44px tall.
  • Contrast: #1b2350 on #f3ecdc is about 13:1. #2338a0 on #f3ecdc is about 8:1. Pink is never used for text.
  • Proximity snapping never traps a reader mid paragraph. Do not switch to mandatory.

Responsive rules

  • 390×844: as specified.
  • 360 wide: same layout. The headline wraps to 2 lines, the paragraph to 4. Segments are about 52px wide. The button label “Order a loaf · £6.50” fits on one line.
  • Under 780px tall: number 96px, headline 36px, drawing min-height 120px and max width min(200px, 54vw). Every chapter must still fit one screen, so the header never covers the number at the end of the page.
  • Taller than 844: the drawing area grows, text stays the same size.
  • 600px and wider (small tablet): content is capped to a 480px column centred, and the bar matches it. Do not stretch the 128px number across a tablet.
  • Landscape phone: chapters grow taller than the screen, snapping still settles on their tops. Do not shrink type further.
  • Never overflow sideways. The waves drawing clips to its own viewBox.

Acceptance checklist

Always

  • Each chapter is a full-height section with scroll-snap-align: start. The root uses scroll-snap-type: y proximity, not mandatory.
  • A fixed bar shows one segment per chapter, each a button at least 40px tall.
  • Segment fill is driven by scroll position with scaleX, from one passive listener and one rAF per frame.
  • Exactly one segment has aria-current="step" and it matches the chapter at mid screen.
  • Tapping a segment scrolls to that chapter and focuses its headline.
  • Chapter content animates in when 45% visible, using only opacity and transform, and resets under 5%.
  • Looping drawings are paused unless their chapter is in view.
  • The last chapter is the call to action with one primary button.
  • The top clears 54px, the bottom clears 84px for the browser bar.
  • Reduced motion: everything visible, no loops, instant jumps.
  • Readable at 360 wide with no sideways scroll.

This demo

  • Six chapters: The loaf, The starter, The mill, The fold, The oven, The ferry.
  • Numbers are Anton 128px #2338a0 with a #ff48b0 copy at 5px, 4px in multiply.
  • The label reads “Ferry Rye · Skerry Island” and “01 / 06” on load.
  • Button reads “Order a loaf · £6.50” with a 5px 4px pink shadow.
  • Page is #f3ecdc with a 4px dot grain at 7% blue.

Implementation notes

1. Fill and current chapter from one scroll read. Read scrollY and each chapter’s offsetTop once per frame. Write only when a value changes.

function frame() {
  ticking = false;
  const y = scrollY, vh = innerHeight;
  let now = 0;
  chapters.forEach((ch, i) => {
    const f = Math.min(1, Math.max(0, (y + vh - ch.offsetTop) / ch.offsetHeight));
    const v = Math.round(f * 1000) / 1000;
    if (v !== segs[i].last) { segs[i].last = v; segs[i].fill.style.transform = `scaleX(${v})`; }
    if (ch.offsetTop <= y + vh * 0.5) now = i;
  });
  if (now !== current) {
    segs[current]?.b.removeAttribute('aria-current');
    segs[now].b.setAttribute('aria-current', 'step');
    where.textContent = `${pad(now + 1)} / ${pad(total)}`;
    current = now;
  }
}
addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(frame); } }, { passive: true });

2. Enter and reset with one observer. Two thresholds give you hysteresis, so a chapter half off screen does not flicker.

const io = new IntersectionObserver(entries => {
  for (const e of entries) {
    if (e.intersectionRatio >= 0.45) e.target.classList.add('in');
    else if (e.intersectionRatio < 0.05) e.target.classList.remove('in');
  }
}, { threshold: [0, 0.05, 0.45] });
chapters.forEach(ch => io.observe(ch));

3. The overprint number. One element, one pseudo copy, multiply.

.num { position: relative; font: 400 128px/.82 var(--display); color: var(--blue); }
.num::after {
  content: attr(data-n); position: absolute; left: 0; top: 0;
  color: var(--pink); mix-blend-mode: multiply;
  transform: translate(5px, 4px);
}
.js .num::after { transform: translate(18px, 12px); transition: transform 900ms var(--ease) 150ms; }
.js .in .num::after { transform: translate(5px, 4px); }

Gate the hidden start state behind a js class on html. Without it, a reader with no script sees six blank chapters.

4. Jump without a double scroll.

button.addEventListener('click', () => {
  ch.scrollIntoView({ behavior: reduce.matches ? 'auto' : 'smooth', block: 'start' });
  ch.querySelector('h2').focus({ preventScroll: true });
});

Common mistakes:

  • scroll-snap-type: y mandatory. On a phone it yanks the reader to the next chapter mid sentence.
  • A 2px tall segment as the button. The bar can be 4px, the button must be 40px.
  • Animating width on the segment fill. Use scaleX with transform-origin: left.
  • Running all six loops at once. Pause them off screen.
  • Making the number the heading. The number is decoration. The h2 is the name.
  • Fitting chapters with height: 100vh and overflow: hidden. Use min-height, so long copy at 360 wide grows instead of clipping.
  • Forgetting the 84px bottom clearance. The browser bar then covers the button.
  • A gradient or glow anywhere. Riso is two flat inks on paper. Overlap is the only blend.

Where it sits:

  1. It is a brand story, a product launch story, or an annual report on a phone. One page, one story.
  2. Keep it to four to eight chapters. Six is this demo.
  3. Each chapter gets one idea, one drawing, one headline and at most 40 words.
  4. The last chapter carries the only primary button on the page.

Rebuild order:

  1. Lay out one chapter at 390×844 with the clearances.
  2. Copy it into six chapters and add snapping.
  3. Build the bar from the chapters and wire the fill.
  4. Wire current chapter, label and segment taps.
  5. Add the observer and the enter transitions.
  6. Draw the six visuals and their motions.
  7. Add the call to action.
  8. Check 360×740, reduced motion and no-JS.

Details

Palette
Type
Anton · Source Serif 4
Motion
Rich motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Susan Acharya

Draw one scene and let it move a little. The words stay calm on top.

All sources