How to Build an Illustrated Scrolling Storyboard

Turn a grid of AI-generated panels into a cinematic scroll-driven novelette. Ken Burns push-in, parallax, chapter pacing, keyboard navigation, progress HUD, fullscreen, and a city-map mini-map. About 600 lines of HTML/CSS/JS, no build step, no dependencies.

✍️ Note from the Creator

Most authors have cover art and maybe a chapter illustration. With AI-generated panels, an indie author can produce 28+ frames in an afternoon, a full visual short story. The problem is presentation. A flat grid of thumbnails on a webpage reads as "concept art dump." A scroll-driven cinematic reads as a film.

This is the build I used for Dariah: Night in New Lancaster, a free 28-panel side-story for The Roar of Winchester. The full source is small enough that you can lift it into any indie author or comic site in under an hour.

The technique works for anything sequential: a graphic-novel short, a recipe walkthrough, a product story, an annual report. Any 20-60 image set with a narrative order benefits from this presentation.

⚙️ Prerequisites

🎬 End Result

This is the live build. Scroll inside the frame to step through the 28 panels.

Live preview embedded from /dariahs-night/Open full page ↗

1 Generate the Panels

Generate panels in 2x2 sheets, four panels per sheet. A 28-panel storyboard is seven sheets. Most image generators produce a 2x2 grid more reliably than four separate calls because the model can keep the character consistent across all four positions in one shot.

Character consistency

The single biggest factor in whether a storyboard reads as one story versus four random images is whether the protagonist looks like the same person across panels. Three approaches that work:

Aspect ratio

Each panel reads best at 16:9 widescreen. A 2x2 grid at 2752x1536 yields four 1376x768 panels. That's the resolution the cropping pipeline assumes. If you generate at a different size, the crop coordinates in Step 2 need to change proportionally.

Tip: If a single panel out of a sheet is junky (text artifacts, character drift), regenerate just that sheet and re-crop. The pipeline assumes you can iterate per-sheet without re-doing the whole project.

2 Crop to Individual Panels

ImageMagick splits each 2x2 sheet into four quadrants. Save the script and run it whenever a sheet is regenerated.

split-sheets.sh
#!/bin/bash
# Split 2x2 storyboard sheets into individual panels.
# Each sheet is 2752x1536; each panel is 1376x768.

OUT=./panels
mkdir -p $OUT

# Sheet → panel mapping. Update filenames + panel numbers per project.
declare -A SHEETS=(
  ["sheet-01.jpg"]="1 2 3 4"
  ["sheet-02.jpg"]="5 6 7 8"
  ["sheet-03.jpg"]="9 10 11 12"
  ["sheet-04.jpg"]="13 14 15 16"
  ["sheet-05.jpg"]="17 18 19 20"
  ["sheet-06.jpg"]="21 22 23 24"
  ["sheet-07.jpg"]="25 26 27 28"
)

# Quadrant offsets in reading order: TL, TR, BL, BR
OFFSETS=("+0+0" "+1376+0" "+0+768" "+1376+768")

for sheet in "${!SHEETS[@]}"; do
  read -ra panels <<< "${SHEETS[$sheet]}"
  for i in 0 1 2 3; do
    n=$(printf "%02d" "${panels[$i]}")
    convert "$sheet" -crop "1376x768${OFFSETS[$i]}" +repage "$OUT/panel-${n}.jpg" &
  done
done
wait
echo "Done. $(ls $OUT/*.jpg | wc -l) panels written."
Why parallel: The & at the end of each convert call backgrounds it. 28 panels crop in roughly the time of one. The wait at the end ensures the script doesn't exit until all crops finish.

Optional: WebP compression

JPG at quality 85 lands around 150-250KB per panel. WebP at quality 80 cuts that roughly in half. For 28 panels that's a 4MB total vs 8MB. Worth it.

to-webp.sh
for f in panels/panel-*.jpg; do
  cwebp -q 80 -m 6 "$f" -o "${f%.jpg}.webp"
done

3 HTML & CSS Scaffold

Every panel is its own full-viewport section inside a scroll-snap container. The image is positioned with background-image on a centered div sized to the panel's aspect ratio. The CSS letterboxes the panel with black bars top and bottom for the cinemascope look.

panel-section.html
<section class="panel" data-panel="1" data-chapter="I">
  <div class="panel-frame">
    <div class="panel-img"
         style="background-image:url('panels/panel-01.jpg')"></div>
    <div class="prose prose-bl">
      <span class="prose-meta">Panel 01</span>
      Your caption text here.
    </div>
  </div>
</section>
panel-styles.css
body{background:#000;scroll-snap-type:y mandatory;overflow-y:scroll}

.panel{
  position:relative;height:100vh;width:100%;
  display:flex;align-items:center;justify-content:center;
  background:#000;overflow:hidden;
  scroll-snap-align:start;
}
.panel-img{
  position:absolute;top:50%;left:50%;
  width:100%;max-width:1400px;
  aspect-ratio:1376/768;
  transform:translate(-50%,-50%) scale(1);
  background-size:cover;background-position:center;
  transition:transform 1.6s cubic-bezier(.22,.61,.36,1);
  box-shadow:0 30px 100px rgba(0,0,0,0.8);
}
/* Ken Burns push-in when panel enters viewport */
.panel.in-view .panel-img{
  transform:translate(-50%,-50%) scale(1.06);
}
/* Cinemascope vignette */
.panel-img::before{
  content:'';position:absolute;inset:0;
  background:radial-gradient(ellipse at center,
    transparent 50%,rgba(0,0,0,0.45) 100%);
  pointer-events:none;
}

/* Prose overlay */
.prose{
  position:absolute;z-index:3;max-width:520px;
  font-family:'Cormorant Garamond',serif;
  font-size:clamp(1.1rem,1.6vw,1.45rem);
  line-height:1.55;color:#f4ede0;
  padding:1.5rem 2rem;
  background:linear-gradient(180deg,
    rgba(0,0,0,0),rgba(0,0,0,0.55));
  backdrop-filter:blur(2px);
  opacity:0;transform:translateY(20px);
  transition:opacity 1.2s ease 0.4s,
             transform 1.2s ease 0.4s;
  text-shadow:0 2px 8px rgba(0,0,0,0.8);
}
.panel.in-view .prose{opacity:1;transform:translateY(0)}
.prose-bl{bottom:8vh;left:5vw}
.prose-br{bottom:8vh;right:5vw;text-align:right}
.prose-tl{top:10vh;left:5vw}
.prose-tr{top:10vh;right:5vw;text-align:right}

Three things to notice:

Chapter dividers

Between groups of panels, drop in a non-panel section that breaks the rhythm. This is what makes the experience feel structured instead of flat.

chapter-divider.html
<section class="chapter">
  <div class="chapter-num">Chapter II</div>
  <h2 class="chapter-title">The City</h2>
  <div class="chapter-rule"></div>
</section>

<style>
.chapter{height:70vh;display:flex;flex-direction:column;
  align-items:center;justify-content:center;
  background:#000;scroll-snap-align:start}
.chapter-num{font-size:.7rem;letter-spacing:.6em;
  text-transform:uppercase;color:#8a8378;margin-bottom:1.5rem}
.chapter-title{font-family:'Cormorant Garamond',serif;
  font-size:clamp(2.5rem,6vw,4.5rem);font-weight:300;
  font-style:italic;color:#e8e2d8}
.chapter-rule{width:4rem;height:1px;background:#e85a9c;
  margin:2rem auto 0}
</style>

4 The Scroll Engine

IntersectionObserver detects when each panel locks into the viewport and toggles the .in-view class. That single class triggers the Ken Burns push-in and the prose fade-in via CSS. No animation libraries, no requestAnimationFrame loop.

scroll-engine.js
const panels = document.querySelectorAll('.panel');
const hudPanel = document.getElementById('hudPanel');
const progressBar = document.getElementById('progressBar');

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('in-view');
      const num = entry.target.dataset.panel;
      if (num) {
        hudPanel.textContent = String(num).padStart(2,'0') + ' / 28';
        progressBar.style.width = (parseInt(num) / 28 * 100) + '%';
      }
    } else {
      entry.target.classList.remove('in-view');
    }
  });
}, { threshold: 0.4 });

panels.forEach(p => observer.observe(p));
Threshold tuning: 0.4 means a panel is considered "in view" once 40% of it is visible. Higher values delay the transition until the panel is more centered. Lower values trigger sooner but can feel premature on slow scrolls. 0.4 is the sweet spot for full-viewport sections.

Chapter pacing

For chapters that should feel slower (an emotional beat, a quiet moment), add a .bridge class to those panels and slow their CSS transition. The reader feels the pacing shift even without realizing why.

chapter-pacing.css
.panel.bridge .panel-img{transition:transform 3s ease-out}
.panel.bridge.in-view .panel-img{
  transform:translate(-50%,-50%) scale(1.04);
}

5 Navigation Controls

Scroll-snap on its own is fine on desktop but feels sluggish on long scrolls. Adding keyboard, touch-swipe, and clickable progress-bar nav makes the experience feel deliberate at any input device.

Keyboard navigation

keyboard-nav.js
const navSections = Array.from(
  document.querySelectorAll('[data-nav]')
);

function navigateTo(direction) {
  let target = null;
  if (direction === 'next') {
    for (const sec of navSections) {
      if (sec.getBoundingClientRect().top > 50) { target = sec; break; }
    }
  } else {
    for (let i = navSections.length - 1; i >= 0; i--) {
      if (navSections[i].getBoundingClientRect().top < -50) {
        target = navSections[i]; break;
      }
    }
  }
  if (target) target.scrollIntoView({behavior:'smooth',block:'start'});
}

document.addEventListener('keydown', (e) => {
  if (e.target.tagName === 'INPUT') return;
  switch(e.key) {
    case 'ArrowDown': case 'ArrowRight':
    case 'PageDown': case ' ':
      e.preventDefault(); navigateTo('next'); break;
    case 'ArrowUp': case 'ArrowLeft': case 'PageUp':
      e.preventDefault(); navigateTo('prev'); break;
    case 'Home':
      e.preventDefault();
      window.scrollTo({top:0,behavior:'smooth'}); break;
    case 'End':
      e.preventDefault();
      window.scrollTo({top:document.body.scrollHeight,behavior:'smooth'}); break;
  }
});

Each navigable section (panels, chapter dividers, title, end screen) needs the data-nav attribute. The function walks the array to find the next-or-previous section relative to current scroll position.

Touch swipe (mobile)

touch-swipe.js
let touchStartY = 0, touchStartX = 0, touchStartTime = 0;

document.addEventListener('touchstart', (e) => {
  touchStartY = e.touches[0].clientY;
  touchStartX = e.touches[0].clientX;
  touchStartTime = Date.now();
}, { passive: true });

document.addEventListener('touchend', (e) => {
  const dy = e.changedTouches[0].clientY - touchStartY;
  const dx = e.changedTouches[0].clientX - touchStartX;
  const dt = Date.now() - touchStartTime;
  // Quick decisive vertical swipe (>50px, <500ms, mostly vertical)
  if (dt < 500 && Math.abs(dy) > 50 && Math.abs(dy) > Math.abs(dx) * 1.5) {
    if (dy < 0) navigateTo('next');
    else navigateTo('prev');
  }
}, { passive: true });
Heads up: Don't use preventDefault on touch events. Browsers warn about non-passive touch handlers, and the user's native scroll-snap will fight any custom touch handling. The pattern above augments scroll-snap (jumping one section on a quick swipe) rather than replacing it.

Clickable progress bar

A thin progress bar at the top of the page doubles as a navigation control. Click anywhere on it to jump to that panel. Hover shows a tooltip with the panel number.

progress-bar.js
const progressEl = document.getElementById('progress');

progressEl.addEventListener('click', (e) => {
  const rect = progressEl.getBoundingClientRect();
  const pct = (e.clientX - rect.left) / rect.width;
  const targetPanel = Math.max(1, Math.min(28, Math.ceil(pct * 28)));
  const panelEl = document.querySelector(
    `.panel[data-panel="${targetPanel}"]`
  );
  if (panelEl) panelEl.scrollIntoView({behavior:'smooth',block:'start'});
});

6 Polish Layers

Three optional details that take the experience from "scroll-snapping image gallery" to "this is intentional."

Mouse parallax (desktop)

Move the panel image a few pixels opposite the cursor position. Subtle, but it adds a 3D feel without nausea.

mouse-parallax.js
const supportsHover = window.matchMedia('(hover: hover)').matches;
if (supportsHover) {
  panels.forEach(panel => {
    const img = panel.querySelector('.panel-img');
    panel.addEventListener('mousemove', (e) => {
      if (!panel.classList.contains('in-view')) return;
      const rect = panel.getBoundingClientRect();
      const x = (e.clientX - rect.left) / rect.width - 0.5;
      const y = (e.clientY - rect.top) / rect.height - 0.5;
      const tx = -x * 14;
      const ty = -y * 10;
      img.style.transform =
        `translate(calc(-50% + ${tx}px),calc(-50% + ${ty}px)) scale(1.08)`;
    });
    panel.addEventListener('mouseleave', () => {
      img.style.transform = '';
    });
  });
}

The matchMedia('(hover: hover)') check skips this on touch devices where it would only cause jank.

Snow / particle overlay

A pure-CSS particle layer adds ambient motion behind everything without a single JS frame. Pre-baked radial gradients at random positions, animated with a background-position shift.

snow.css
.snow{
  position:fixed;inset:0;pointer-events:none;z-index:10;
  background-image:
    radial-gradient(2px 2px at 10% 20%,rgba(255,255,255,0.7),transparent),
    radial-gradient(1.5px 1.5px at 30% 60%,rgba(255,255,255,0.55),transparent),
    radial-gradient(2.5px 2.5px at 70% 30%,rgba(255,255,255,0.65),transparent),
    radial-gradient(1px 1px at 85% 75%,rgba(255,255,255,0.5),transparent),
    radial-gradient(2px 2px at 50% 45%,rgba(255,255,255,0.6),transparent);
  background-size:100% 100%;
  animation:snowDrift 60s linear infinite;
  opacity:0.4;
}
@keyframes snowDrift{
  from{background-position:0 0,0 0,0 0,0 0,0 0}
  to{background-position:50px 800px,-30px 900px,40px 850px,-20px 1000px,30px 950px}
}

Fullscreen toggle

One button in the corner toggles document fullscreen. Browsers handle the rest.

fullscreen.js
function toggleFullscreen() {
  if (!document.fullscreenElement) {
    document.documentElement.requestFullscreen().catch(console.log);
  } else {
    document.exitFullscreen();
  }
}
document.getElementById('fsBtn').addEventListener('click', toggleFullscreen);
document.addEventListener('keydown', (e) => {
  if (e.key === 'f' || e.key === 'F') toggleFullscreen();
});

7 Deploy

Static hosting. Nothing fancy needed.

  1. Final folder structure:
    folder layout
    storyboard/
      index.html         # single self-contained file
      panels/
        panel-01.jpg
        panel-02.jpg
        ...
        panel-28.jpg
  2. Total size: ~5MB with WebP, ~10MB with JPG. Acceptable for a one-page experience.
  3. Add <link rel="preload" as="image" href="panels/panel-01.jpg"> for the first 3-4 panels in <head>. The rest browser-fetch as the user scrolls. The first paint is fast.
  4. If served behind Cloudflare or another CDN, purge the path after deploys so the next visitor gets the latest version.
SEO & sharing: Set OG image to one of the strongest panels (the most cinematic single frame). When the URL is shared on social, that image is what people see. It does the work of an entire poster.

⊞ How It Works

The whole system is three layers:

  1. Scroll-snap handles positioning. The browser locks each panel into the viewport as the user scrolls. No JS involved in this layer.
  2. IntersectionObserver watches each panel. When one becomes visible, it gets a CSS class. When it leaves, the class is removed. Everything visual (Ken Burns, prose fade, HUD update, progress bar, map dot) keys off that single class.
  3. Optional control surfaces (keyboard, swipe, progress click, fullscreen) call scrollIntoView on the next/previous section. Scroll-snap then takes over again.

That's the architecture. The fancy presentation (parallax, snow, chapter pacing, map HUD) is all decorative CSS layered on top of those three primitives. Lifting just the three primitives gives a working scrolling storyboard. Each polish layer is independent and can be dropped without breaking anything else.

? Troubleshooting

Scroll-snap feels sluggish

Use scroll-snap-type: y mandatory, not y proximity. Mandatory locks every panel. Proximity leaves the door open for the browser to skip, which feels broken on this kind of layout.

Ken Burns flickers on Safari

Add will-change: transform to .panel-img. Safari otherwise composites the transform on the CPU which causes the flicker.

Prose overlap on mobile

The four prose positions (TL/TR/BL/BR) reposition to a single full-width block on screens narrower than 768px. Always test mobile separately; what looks balanced on desktop can stack awkwardly.

Touch swipe fights scroll-snap

Use {passive: true} on the touchstart/touchend listeners and only trigger custom navigation on a quick, decisive vertical swipe (>50px in <500ms). Long or slow swipes should fall through to native scroll-snap.

Images load slowly on the first panel

Preload the first 3-4 panels in <head> with <link rel="preload" as="image">. The remaining panels can lazy-load naturally as the user approaches them.

The fullscreen button doesn't work

The Fullscreen API requires a user gesture. Bind the toggle to a click handler, not to page load or scroll. Some browsers also block fullscreen inside iframes; if the storyboard is embedded somewhere, fullscreen will only work when opened in its own tab.