Storybook — Picture-Book Reader
A friendly picture-book reader that turns illustrated two-page spreads of a bedtime tale, each drawn entirely in inline SVG with a sentence of story text. Big rounded prev and next buttons flip the pages with a gentle curl-and-tilt animation, a spread indicator and progress bar track your place, and a thumbnail strip jumps anywhere. Arrow keys turn pages, the final spread bursts into a The End confetti celebration, scene characters wiggle on tap, and an easy-read font toggle helps young readers.
MCP
Code
:root {
--bg: #fff8ef;
--bg-2: #ffeede;
--ink: #2c2350;
--ink-soft: #6b5f8a;
--primary: #ff8a3d;
--secondary: #5ec5d6;
--accent: #ffd23f;
--pink: #ff6f9c;
--green: #7bd389;
--paper: #fffdf8;
--line: #2c2350;
--r: 22px;
--r-lg: 30px;
--shadow: 0 14px 30px rgba(44, 35, 80, 0.14);
--shadow-sm: 0 6px 14px rgba(44, 35, 80, 0.12);
--display: "Baloo 2", system-ui, sans-serif;
--body: "Nunito", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--body);
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(1100px 520px at 12% -8%, #ffe6cf 0%, transparent 60%),
radial-gradient(900px 480px at 108% 4%, #d7f2f6 0%, transparent 58%),
radial-gradient(800px 600px at 50% 120%, #ffe2ec 0%, transparent 55%),
var(--bg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Dyslexia / easy-read friendly mode */
body.easy-read {
--body: "Comic Sans MS", "Nunito", system-ui, sans-serif;
letter-spacing: 0.035em;
word-spacing: 0.12em;
line-height: 1.7;
}
.skip-link {
position: absolute;
left: -999px;
top: 8px;
z-index: 50;
background: var(--ink);
color: #fff;
padding: 10px 16px;
border-radius: 999px;
font-weight: 700;
}
.skip-link:focus {
left: 12px;
}
:focus-visible {
outline: 3px solid var(--secondary);
outline-offset: 3px;
border-radius: 6px;
}
/* ---------- Top bar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 16px clamp(14px, 4vw, 40px);
max-width: 1080px;
margin: 0 auto;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: grid;
place-items: center;
width: 56px;
height: 56px;
background: #fff;
border: 3px solid var(--line);
border-radius: 18px;
box-shadow: var(--shadow-sm);
}
.brand-text {
font-family: var(--display);
font-weight: 800;
font-size: 1.5rem;
letter-spacing: 0.01em;
}
.topbar-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.chip {
font-family: var(--display);
font-weight: 700;
font-size: 0.95rem;
color: var(--ink);
background: #fff;
border: 3px solid var(--line);
border-radius: 999px;
padding: 10px 18px;
min-height: 48px;
cursor: pointer;
box-shadow: 0 4px 0 rgba(44, 35, 80, 0.18);
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
}
.chip:hover {
background: var(--bg-2);
}
.chip:active {
transform: translateY(3px);
box-shadow: 0 1px 0 rgba(44, 35, 80, 0.18);
}
.chip[aria-pressed="true"] {
background: var(--accent);
}
/* ---------- Reader ---------- */
.reader {
max-width: 1080px;
margin: 0 auto;
padding: 6px clamp(14px, 4vw, 40px) 56px;
}
.story-title {
font-family: var(--display);
font-weight: 800;
font-size: clamp(1.7rem, 5vw, 2.7rem);
text-align: center;
margin: 6px 0 2px;
color: var(--ink);
text-shadow: 2px 2px 0 var(--accent);
}
.story-by {
text-align: center;
margin: 0 0 18px;
color: var(--ink-soft);
font-weight: 700;
}
/* ---------- Stage with turn buttons ---------- */
.stage {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: clamp(8px, 2vw, 22px);
}
.turner {
flex: none;
width: 64px;
height: 64px;
display: grid;
place-items: center;
font-family: var(--display);
font-size: 2.2rem;
line-height: 1;
color: #fff;
background: var(--primary);
border: 3px solid var(--line);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 6px 0 rgba(44, 35, 80, 0.22);
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease, opacity 0.2s ease;
}
.turner span {
margin-top: -4px;
}
.turner-next {
background: var(--pink);
}
.turner:hover:not(:disabled) {
transform: translateY(-2px) scale(1.04);
}
.turner:active:not(:disabled) {
transform: translateY(4px);
box-shadow: 0 2px 0 rgba(44, 35, 80, 0.22);
}
.turner:disabled {
opacity: 0.35;
cursor: not-allowed;
box-shadow: 0 2px 0 rgba(44, 35, 80, 0.18);
}
/* ---------- The book ---------- */
.book {
position: relative;
perspective: 1800px;
}
.spread {
display: grid;
grid-template-columns: 1fr 1fr;
background: var(--paper);
border: 4px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: var(--shadow);
min-height: 0;
}
/* center spine */
.spread::after {
content: "";
position: absolute;
inset: 4px auto 4px 50%;
width: 26px;
transform: translateX(-50%);
background: linear-gradient(
to right,
transparent,
rgba(44, 35, 80, 0.12) 42%,
rgba(44, 35, 80, 0.2) 50%,
rgba(44, 35, 80, 0.12) 58%,
transparent
);
pointer-events: none;
}
.page {
position: relative;
padding: clamp(14px, 2.6vw, 26px);
display: flex;
flex-direction: column;
gap: 14px;
}
.page-left {
background:
linear-gradient(180deg, #fffdf8, #fff5e8);
}
.page-right {
background:
linear-gradient(180deg, #fffdf8, #f1fbfc);
}
.scene {
position: relative;
aspect-ratio: 4 / 3;
border: 3px solid var(--line);
border-radius: var(--r);
overflow: hidden;
background: #cdeefb;
box-shadow: inset 0 -8px 0 rgba(44, 35, 80, 0.06);
}
.scene svg {
display: block;
width: 100%;
height: 100%;
}
/* tappable scene elements */
.tap {
cursor: pointer;
transform-box: fill-box;
transform-origin: center;
transition: transform 0.18s ease;
}
.tap:hover {
transform: scale(1.05);
}
.tap.wiggle {
animation: wiggle 0.55s ease;
}
@keyframes wiggle {
0%, 100% { transform: rotate(0) scale(1); }
25% { transform: rotate(-9deg) scale(1.08); }
55% { transform: rotate(7deg) scale(1.08); }
80% { transform: rotate(-4deg) scale(1.04); }
}
.story-text {
font-family: var(--display);
font-weight: 600;
font-size: clamp(1rem, 1.9vw, 1.3rem);
color: var(--ink);
margin: 0;
background: #fff;
border: 3px dashed rgba(44, 35, 80, 0.3);
border-radius: var(--r);
padding: 14px 16px;
flex: 1;
display: flex;
align-items: center;
}
.page-num {
position: absolute;
bottom: 8px;
font-family: var(--display);
font-weight: 700;
font-size: 0.82rem;
color: var(--ink-soft);
}
.page-left .page-num { left: 18px; }
.page-right .page-num { right: 18px; }
/* page-turn / flip animation */
.spread.turn-next {
animation: turnNext 0.5s ease;
}
.spread.turn-prev {
animation: turnPrev 0.5s ease;
}
@keyframes turnNext {
0% { transform: rotateY(0); }
45% { transform: rotateY(-16deg); filter: brightness(0.92); }
100% { transform: rotateY(0); }
}
@keyframes turnPrev {
0% { transform: rotateY(0); }
45% { transform: rotateY(16deg); filter: brightness(0.92); }
100% { transform: rotateY(0); }
}
/* corner page curl */
.page-curl {
position: absolute;
right: 6px;
bottom: 6px;
width: 46px;
height: 46px;
border-radius: 0 0 var(--r-lg) 0;
background: linear-gradient(135deg, transparent 50%, rgba(44, 35, 80, 0.12) 50%, #fff 52%);
border-right: 4px solid var(--line);
border-bottom: 4px solid var(--line);
box-shadow: -4px -4px 8px rgba(44, 35, 80, 0.12) inset;
pointer-events: none;
}
/* ---------- The End celebration ---------- */
.spread.the-end .page {
align-items: center;
justify-content: center;
text-align: center;
}
.the-end-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.the-end-card h2 {
font-family: var(--display);
font-weight: 800;
font-size: clamp(2rem, 7vw, 3.4rem);
margin: 0;
color: var(--primary);
text-shadow: 2px 2px 0 var(--accent);
animation: pop 0.6s ease;
}
.the-end-card p {
margin: 0;
font-weight: 700;
color: var(--ink-soft);
}
@keyframes pop {
0% { transform: scale(0.3); opacity: 0; }
70% { transform: scale(1.12); }
100% { transform: scale(1); opacity: 1; }
}
/* confetti */
.confetti {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.confetti i {
position: absolute;
top: -12px;
width: 12px;
height: 16px;
border-radius: 3px;
animation: fall linear forwards;
}
@keyframes fall {
0% { transform: translateY(-20px) rotate(0); opacity: 1; }
100% { transform: translateY(420px) rotate(540deg); opacity: 0; }
}
/* ---------- Progress ---------- */
.progress-row {
display: flex;
align-items: center;
gap: 16px;
margin: 20px auto 0;
max-width: 640px;
}
.page-indicator {
margin: 0;
font-weight: 700;
color: var(--ink-soft);
white-space: nowrap;
}
.page-indicator strong {
font-family: var(--display);
color: var(--ink);
}
.progress-track {
flex: 1;
height: 16px;
background: #fff;
border: 3px solid var(--line);
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
display: block;
height: 100%;
width: 8%;
background: linear-gradient(90deg, var(--secondary), var(--green));
border-radius: 999px;
transition: width 0.4s ease;
}
/* ---------- Thumbnails ---------- */
.thumbs {
margin-top: 22px;
}
.thumb-strip {
list-style: none;
margin: 0;
padding: 8px 4px 14px;
display: flex;
gap: 12px;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: thin;
}
.thumb {
scroll-snap-align: center;
flex: none;
}
.thumb button {
width: 84px;
height: 64px;
padding: 0;
border: 3px solid var(--line);
border-radius: 14px;
overflow: hidden;
cursor: pointer;
background: #cdeefb;
position: relative;
box-shadow: 0 4px 0 rgba(44, 35, 80, 0.16);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.thumb button svg {
width: 100%;
height: 100%;
display: block;
}
.thumb button:hover {
transform: translateY(-3px);
}
.thumb button .thumb-no {
position: absolute;
left: 5px;
bottom: 4px;
font-family: var(--display);
font-weight: 700;
font-size: 0.68rem;
color: var(--ink);
background: var(--accent);
border: 2px solid var(--line);
border-radius: 7px;
padding: 0 5px;
line-height: 1.3;
}
.thumb.is-active button {
outline: 4px solid var(--pink);
outline-offset: 2px;
transform: translateY(-3px);
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 22px;
transform: translate(-50%, 24px);
background: var(--ink);
color: #fff;
font-family: var(--display);
font-weight: 700;
padding: 12px 22px;
border-radius: 999px;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 60;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Responsive ---------- */
@media (max-width: 640px) {
.turner {
width: 52px;
height: 52px;
font-size: 1.8rem;
}
.spread {
grid-template-columns: 1fr;
}
/* show only the right (story) page stacked on the illustration on phones */
.spread::after {
inset: auto 4px 50% 4px;
width: auto;
height: 22px;
transform: none;
background: linear-gradient(
to bottom,
transparent,
rgba(44, 35, 80, 0.16) 50%,
transparent
);
}
.progress-row {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.page-indicator {
text-align: center;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}(function () {
"use strict";
/* ---------- tiny SVG scene builders (no external images) ---------- */
// Each returns an inline SVG string. Shapes marked class="tap" wiggle on tap.
function sky(grad) {
return (
'<defs>' +
'<linearGradient id="' + grad + '" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0" stop-color="#bfe9ff"/><stop offset="1" stop-color="#e9f8ff"/>' +
'</linearGradient></defs>' +
'<rect width="200" height="150" fill="url(#' + grad + ')"/>'
);
}
function fox(x, y, s) {
s = s || 1;
return (
'<g class="tap" transform="translate(' + x + ',' + y + ') scale(' + s + ')" role="img" aria-label="Pip the lantern fox">' +
'<ellipse cx="0" cy="20" rx="20" ry="7" fill="#2c2350" opacity="0.12"/>' +
'<path d="M-16 6 Q-22 -14 -10 -10 Z" fill="#ff8a3d" stroke="#2c2350" stroke-width="2"/>' +
'<path d="M16 6 Q22 -14 10 -10 Z" fill="#ff8a3d" stroke="#2c2350" stroke-width="2"/>' +
'<ellipse cx="0" cy="6" rx="16" ry="14" fill="#ff9d57" stroke="#2c2350" stroke-width="2"/>' +
'<ellipse cx="0" cy="11" rx="9" ry="8" fill="#fff6ea"/>' +
'<circle cx="-6" cy="3" r="2.4" fill="#2c2350"/>' +
'<circle cx="6" cy="3" r="2.4" fill="#2c2350"/>' +
'<circle cx="0" cy="9" r="2.2" fill="#2c2350"/>' +
'<path d="M18 14 Q34 6 30 24 Q24 22 18 18 Z" fill="#ff8a3d" stroke="#2c2350" stroke-width="2"/>' +
'</g>'
);
}
function lantern(x, y, glow) {
return (
'<g class="tap" transform="translate(' + x + ',' + y + ')" role="img" aria-label="Glowing lantern">' +
(glow ? '<circle cx="0" cy="6" r="22" fill="#ffd23f" opacity="0.35"/>' : '') +
'<rect x="-7" y="-2" width="14" height="18" rx="4" fill="#ffe48a" stroke="#2c2350" stroke-width="2"/>' +
'<rect x="-9" y="-6" width="18" height="5" rx="2" fill="#5ec5d6" stroke="#2c2350" stroke-width="2"/>' +
'<rect x="-9" y="15" width="18" height="5" rx="2" fill="#5ec5d6" stroke="#2c2350" stroke-width="2"/>' +
'<path d="M0 -6 v-6" stroke="#2c2350" stroke-width="2"/>' +
'<circle cx="0" cy="7" r="4" fill="#fff3c4"/>' +
'</g>'
);
}
function tree(x, y) {
return (
'<g class="tap" transform="translate(' + x + ',' + y + ')" role="img" aria-label="Tree">' +
'<rect x="-5" y="0" width="10" height="26" rx="4" fill="#a8723e" stroke="#2c2350" stroke-width="2"/>' +
'<circle cx="0" cy="-8" r="18" fill="#7bd389" stroke="#2c2350" stroke-width="2"/>' +
'<circle cx="-13" cy="2" r="12" fill="#7bd389" stroke="#2c2350" stroke-width="2"/>' +
'<circle cx="13" cy="2" r="12" fill="#7bd389" stroke="#2c2350" stroke-width="2"/>' +
'<circle cx="-5" cy="-6" r="2" fill="#ff6f9c"/>' +
'<circle cx="7" cy="-2" r="2" fill="#ffd23f"/>' +
'</g>'
);
}
function star(x, y) {
return (
'<path class="tap" transform="translate(' + x + ',' + y + ')" role="img" aria-label="Star" ' +
'd="M0 -7 2 -2 7 -2 3 2 4 7 0 4 -4 7 -3 2 -7 -2 -2 -2 Z" ' +
'fill="#ffd23f" stroke="#2c2350" stroke-width="1.6"/>'
);
}
function moon(x, y) {
return (
'<g class="tap" transform="translate(' + x + ',' + y + ')" role="img" aria-label="Moon">' +
'<circle cx="0" cy="0" r="14" fill="#fff3c4" stroke="#2c2350" stroke-width="2"/>' +
'<circle cx="5" cy="-3" r="3" fill="#ffe48a"/>' +
'<circle cx="-3" cy="4" r="2" fill="#ffe48a"/>' +
'</g>'
);
}
function hill(color) {
return '<path d="M0 150 Q60 110 120 130 Q170 145 200 120 V150 Z" fill="' + color + '" stroke="#2c2350" stroke-width="2"/>';
}
function nightSky() {
return (
'<defs><linearGradient id="ng" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0" stop-color="#3b3170"/><stop offset="1" stop-color="#7a6fb0"/>' +
'</linearGradient></defs><rect width="200" height="150" fill="url(#ng)"/>'
);
}
/* ---------- the 12 spreads (left scene + right story sentence) ---------- */
var SPREADS = [
{
svg: sky("s0") + hill("#7bd389") + tree(48, 96) + fox(120, 110) + lantern(155, 78, false) + '<circle cx="40" cy="34" r="13" fill="#ffd23f" stroke="#2c2350" stroke-width="2"/>',
text: "Once upon a leafy morning, a small fox named Pip found an old lantern under the wishing tree."
},
{
svg: sky("s1") + hill("#9fe0a8") + fox(70, 108) + lantern(118, 96, true) + star(150, 30) + star(170, 52),
text: "When Pip tapped the lantern twice, a warm honey-coloured light bloomed inside it."
},
{
svg: nightSky() + moon(40, 36) + star(80, 24) + star(120, 40) + star(160, 22) + hill("#5b5197") + fox(96, 116) + lantern(132, 100, true),
text: "Bravely, Pip carried the glowing lantern into the hush of the deep blue night."
},
{
svg: nightSky() + star(30, 30) + star(70, 18) + tree(160, 100) + fox(60, 116) + lantern(96, 100, true) + '<path d="M120 130 q20 -14 44 0" stroke="#5ec5d6" stroke-width="3" fill="none"/>',
text: "A sleepy river whispered, asking Pip to light the path across its smooth stone bridge."
},
{
svg: nightSky() + moon(165, 34) + tree(40, 102) + tree(70, 108) + fox(120, 116) + lantern(150, 102, true),
text: "Through the whispering forest they went, where every tree leaned close to see the light."
},
{
svg: nightSky() + star(50, 26) + star(150, 30) + hill("#4d4488") + fox(60, 114) + lantern(96, 98, true) +
'<g class="tap" transform="translate(150,108)"><ellipse cx="0" cy="6" rx="14" ry="6" fill="#2c2350" opacity="0.12"/><circle cx="0" cy="-2" r="11" fill="#c7b9f0" stroke="#2c2350" stroke-width="2"/><circle cx="-4" cy="-4" r="1.8" fill="#2c2350"/><circle cx="4" cy="-4" r="1.8" fill="#2c2350"/></g>',
text: "A shy little owl was lost in the dark, so Pip shared the lantern's gentle gleam."
},
{
svg: nightSky() + star(40, 22) + star(80, 40) + star(120, 20) + hill("#5b5197") + fox(70, 114) + lantern(106, 98, true) +
'<g class="tap" transform="translate(150,112)"><circle cx="0" cy="0" r="9" fill="#ff9d57" stroke="#2c2350" stroke-width="2"/><circle cx="0" cy="-4" r="2" fill="#fff"/></g>',
text: "Together they found a hedgehog curled by a rock, dreaming of a warmer, brighter glen."
},
{
svg: nightSky() + moon(35, 40) + star(160, 26) + hill("#4d4488") + fox(64, 114) + lantern(100, 98, true) + tree(160, 104) + star(120, 30),
text: "The friends climbed a hill of soft moss, the lantern swinging like a tiny golden sun."
},
{
svg: nightSky() + star(30, 24) + star(70, 20) + star(110, 30) + star(150, 18) + hill("#5b5197") +
'<path d="M70 120 L100 70 L130 120 Z" fill="#6f64aa" stroke="#2c2350" stroke-width="2"/>' + lantern(100, 92, true),
text: "At the very top waited a quiet mountain, wrapped in stars and waiting to be warmed."
},
{
svg: nightSky() + moon(160, 36) + star(40, 24) + star(90, 18) + hill("#4d4488") + fox(70, 114) + lantern(106, 96, true) +
'<circle cx="100" cy="60" r="30" fill="#ffd23f" opacity="0.25"/>',
text: "Pip held the lantern high, and its light spilled across the sleepy valley below."
},
{
svg: '<defs><linearGradient id="dawn" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffd9a0"/><stop offset="1" stop-color="#ffeede"/></linearGradient></defs>' +
'<rect width="200" height="150" fill="url(#dawn)"/><circle cx="100" cy="120" r="34" fill="#ffd23f" stroke="#2c2350" stroke-width="2"/>' +
hill("#7bd389") + tree(40, 100) + fox(120, 110) + lantern(154, 92, false),
text: "When morning tiptoed in, every friend was safe and warm, and Pip's heart was full."
},
{
end: true,
text: "And so the little lantern fox curled up to sleep, ready for tomorrow's adventure."
}
];
/* ---------- elements ---------- */
var spreadEl = document.getElementById("spread");
var prevBtn = document.getElementById("prevBtn");
var nextBtn = document.getElementById("nextBtn");
var pageNow = document.getElementById("pageNow");
var pageTotal = document.getElementById("pageTotal");
var progressFill = document.getElementById("progressFill");
var thumbStrip = document.getElementById("thumbStrip");
var fontToggle = document.getElementById("fontToggle");
var restartBtn = document.getElementById("restartBtn");
var toastEl = document.getElementById("toast");
var total = SPREADS.length;
var current = 0;
var toastTimer = null;
pageTotal.textContent = String(total);
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 1800);
}
function svgWrap(inner) {
return '<svg viewBox="0 0 200 150" preserveAspectRatio="xMidYMid slice" focusable="false">' + inner + "</svg>";
}
function confetti() {
var colors = ["#ff8a3d", "#5ec5d6", "#ffd23f", "#ff6f9c", "#7bd389"];
var box = document.createElement("div");
box.className = "confetti";
box.setAttribute("aria-hidden", "true");
for (var i = 0; i < 26; i++) {
var bit = document.createElement("i");
bit.style.left = Math.random() * 100 + "%";
bit.style.background = colors[i % colors.length];
bit.style.animationDuration = (1.4 + Math.random() * 1.2).toFixed(2) + "s";
bit.style.animationDelay = (Math.random() * 0.5).toFixed(2) + "s";
box.appendChild(bit);
}
return box;
}
function render(dir) {
var data = SPREADS[current];
var html;
if (data.end) {
html =
'<div class="page page-left the-end-left">' +
svgScene("end") +
'<span class="page-num">' + (current * 2 + 1) + "</span>" +
"</div>" +
'<div class="page page-right">' +
'<div class="the-end-card">' +
"<h2>The End</h2>" +
"<p>You finished the story! 🌟</p>" +
'<p style="font-size:0.95rem">' + data.text + "</p>" +
"</div>" +
'<span class="page-num">' + (current * 2 + 2) + "</span>" +
"</div>";
} else {
html =
'<div class="page page-left">' +
'<div class="scene">' + svgWrap(data.svg) + "</div>" +
'<span class="page-num">' + (current * 2 + 1) + "</span>" +
"</div>" +
'<div class="page page-right">' +
'<div class="scene">' + svgWrap(decorScene(current)) + "</div>" +
'<p class="story-text">' + data.text + "</p>" +
'<span class="page-num">' + (current * 2 + 2) + "</span>" +
"</div>";
}
spreadEl.innerHTML = html;
spreadEl.classList.toggle("the-end", !!data.end);
if (data.end) {
spreadEl.querySelector(".the-end-left").appendChild(confetti());
}
// page-turn animation
if (dir) {
spreadEl.classList.remove("turn-next", "turn-prev");
void spreadEl.offsetWidth; // reflow to restart animation
spreadEl.classList.add(dir === "next" ? "turn-next" : "turn-prev");
}
wireTaps();
updateChrome();
}
// a small companion scene for the right page (keeps spread balanced)
function decorScene(i) {
var motifs = [
sky("d" + i) + hill("#7bd389") + lantern(100, 80, true) + star(40, 30) + star(160, 26),
nightSky() + moon(100, 50) + star(40, 30) + star(70, 60) + star(140, 28) + star(165, 60),
nightSky() + star(30, 30) + tree(100, 100) + lantern(60, 80, true) + star(150, 30)
];
return motifs[i % motifs.length];
}
function svgScene(kind) {
if (kind === "end") {
return (
'<div class="scene">' +
svgWrap(
nightSky() + moon(150, 40) + star(30, 30) + star(70, 22) + star(110, 36) +
hill("#4d4488") + fox(80, 116, 1.2) + lantern(120, 100, true)
) +
"</div>"
);
}
return "";
}
function wireTaps() {
var taps = spreadEl.querySelectorAll(".tap");
taps.forEach(function (el) {
el.addEventListener("animationend", function () {
el.classList.remove("wiggle");
});
el.addEventListener("click", function () {
el.classList.remove("wiggle");
void el.getBoundingClientRect();
el.classList.add("wiggle");
});
});
}
function updateChrome() {
pageNow.textContent = String(current + 1);
var pct = ((current + 1) / total) * 100;
progressFill.style.width = pct + "%";
prevBtn.disabled = current === 0;
nextBtn.disabled = current === total - 1;
var items = thumbStrip.querySelectorAll(".thumb");
items.forEach(function (li, idx) {
var active = idx === current;
li.classList.toggle("is-active", active);
var b = li.querySelector("button");
if (b) b.setAttribute("aria-current", active ? "true" : "false");
});
var active = thumbStrip.querySelector(".thumb.is-active");
if (active && active.scrollIntoView) {
active.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
}
}
function go(index, dir) {
index = Math.max(0, Math.min(total - 1, index));
if (index === current && dir) return;
current = index;
render(dir);
if (current === total - 1) {
toast("The End — well read! 🎉");
}
}
/* ---------- thumbnails ---------- */
function buildThumbs() {
SPREADS.forEach(function (data, i) {
var li = document.createElement("li");
li.className = "thumb";
var btn = document.createElement("button");
btn.type = "button";
btn.setAttribute("aria-label", "Go to spread " + (i + 1) + " of " + total);
var inner;
if (data.end) {
inner = nightSky() + moon(150, 40) + star(40, 30) + fox(90, 110, 1.1) + lantern(120, 96, true);
} else {
inner = data.svg;
}
btn.innerHTML = svgWrap(inner) + '<span class="thumb-no" aria-hidden="true">' + (i + 1) + "</span>";
btn.addEventListener("click", function () {
go(i, i > current ? "next" : i < current ? "prev" : null);
});
li.appendChild(btn);
thumbStrip.appendChild(li);
});
}
/* ---------- controls ---------- */
nextBtn.addEventListener("click", function () {
go(current + 1, "next");
});
prevBtn.addEventListener("click", function () {
go(current - 1, "prev");
});
document.addEventListener("keydown", function (e) {
if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
if (e.key === "ArrowRight" || e.key === "PageDown") {
e.preventDefault();
go(current + 1, "next");
} else if (e.key === "ArrowLeft" || e.key === "PageUp") {
e.preventDefault();
go(current - 1, "prev");
} else if (e.key === "Home") {
e.preventDefault();
go(0, "prev");
} else if (e.key === "End") {
e.preventDefault();
go(total - 1, "next");
}
});
fontToggle.addEventListener("click", function () {
var on = document.body.classList.toggle("easy-read");
fontToggle.setAttribute("aria-pressed", on ? "true" : "false");
toast(on ? "Easy-read font on" : "Easy-read font off");
});
restartBtn.addEventListener("click", function () {
go(0, "prev");
toast("Back to the beginning 📖");
});
/* ---------- init ---------- */
buildThumbs();
render(null);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Storybook — Picture-Book Reader</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700;800&family=Nunito:ital,wght@0,400;0,600;0,700;1,600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#book">Skip to the story</a>
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 48 48" width="36" height="36">
<path d="M6 10c6-3 12-3 18 0 6-3 12-3 18 0v28c-6-3-12-3-18 0-6-3-12-3-18 0Z" fill="#fff" stroke="#2c2350" stroke-width="2.5" stroke-linejoin="round"/>
<path d="M24 10v28" stroke="#2c2350" stroke-width="2.5"/>
<path d="M10 17h9M10 23h9M29 17h9M29 23h9" stroke="#5ec5d6" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="24" cy="6" r="3" fill="#ffd23f" stroke="#2c2350" stroke-width="2"/>
</svg>
</span>
<span class="brand-text">Storyleaf</span>
</div>
<div class="topbar-controls">
<button id="fontToggle" class="chip" type="button" aria-pressed="false">
<span aria-hidden="true">Aa</span> Easy-read font
</button>
<button id="restartBtn" class="chip" type="button">
<span aria-hidden="true">↺</span> Restart
</button>
</div>
</header>
<main id="book" class="reader">
<h1 class="story-title">The Little Lantern Fox</h1>
<p class="story-by">A bedtime tale by Marisol Quill</p>
<div class="stage">
<button id="prevBtn" class="turner turner-prev" type="button" aria-label="Previous page" disabled>
<span aria-hidden="true">‹</span>
</button>
<div class="book" role="group" aria-label="Picture book spread" aria-live="polite">
<article id="spread" class="spread">
<!-- left + right pages injected by script.js -->
</article>
<span class="page-curl" aria-hidden="true"></span>
</div>
<button id="nextBtn" class="turner turner-next" type="button" aria-label="Next page">
<span aria-hidden="true">›</span>
</button>
</div>
<div class="progress-row">
<p class="page-indicator" aria-live="polite">
Spread <strong id="pageNow">1</strong> of <strong id="pageTotal">12</strong>
</p>
<div class="progress-track" role="presentation">
<span id="progressFill" class="progress-fill"></span>
</div>
</div>
<nav class="thumbs" aria-label="Jump to a spread">
<ul id="thumbStrip" class="thumb-strip"></ul>
</nav>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Picture-Book Reader
A soft, rounded reader for The Little Lantern Fox, a twelve-spread bedtime story. Each open book shows a left illustration and a right page that pairs a companion scene with a single sentence of story text. Every illustration — the fox, the glowing lantern, friendly owls, mossy hills, and starry night skies — is drawn purely in inline SVG with thick playful ink outlines, so the demo never loads an external image.
The two giant page-turn buttons flip the spread with a curl-and-tilt animation and a paper-curl corner, while the arrow keys (plus Home and End) drive the same navigation from the keyboard. A spread indicator reads Spread 3 of 12, a candy-coloured progress bar fills as you go, and a horizontal thumbnail strip lets readers jump straight to any spread and snap it into view.
Reaching the last spread triggers a bouncing The End card with falling confetti, and tapping any character or prop in a scene gives it a quick, happy wiggle. An easy-read font toggle swaps the body type and loosens letter, word, and line spacing for young or dyslexic readers, all motion respects prefers-reduced-motion, and the spread collapses to a single stacked column down to 360px.
Illustrative kids’ UI only — fictional stories, characters, and audio.