Storybook — Read-Along
A cozy karaoke-style read-along for the fictional bedtime tale The Moon's Lost Mitten, where each word of a CSS-illustrated picture book lights up in sequence as a friendly narrator speaks. A big Read-to-me button, restart, voice mute, and a 0.5x to 1.8x speed slider drive the pacing, while the highlight auto-scrolls and the scene caption updates per page. Tapping any word jumps the narration there, and an easy-read toggle swaps a dyslexia-friendly font with looser spacing. Web Speech API when available, timer fallback otherwise.
MCP
Code
:root {
--bg: #fff8ef;
--card: #ffffff;
--ink: #2c2350;
--ink-soft: #6a6390;
--primary: #ff8a3d;
--primary-deep: #f0741f;
--secondary: #5ec5d6;
--accent: #ffd23f;
--pink: #ff6f9c;
--green: #7bd389;
--purple: #8b6fd6;
--highlight: #ffe39a;
--highlight-edge: #ffc94a;
--r: 24px;
--r-sm: 16px;
--r-pill: 999px;
--shadow: 0 14px 34px rgba(44, 35, 80, 0.16);
--shadow-soft: 0 6px 18px rgba(44, 35, 80, 0.1);
--ring: 0 0 0 4px rgba(94, 197, 214, 0.45);
--font-display: "Baloo 2", system-ui, sans-serif;
--font-body: "Nunito", system-ui, sans-serif;
--word-gap: 0.08em;
--line-h: 1.85;
--letter: normal;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1100px 520px at 88% -8%, rgba(255, 210, 63, 0.32), transparent 60%),
radial-gradient(900px 460px at -6% 8%, rgba(94, 197, 214, 0.26), transparent 60%),
var(--bg);
color: var(--ink);
font-family: var(--font-body);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding: 18px;
display: flex;
justify-content: center;
}
body.dyslexia {
--font-body: "Trebuchet MS", "Comic Sans MS", system-ui, sans-serif;
--word-gap: 0.16em;
--line-h: 2.1;
--letter: 0.03em;
}
.skip-link {
position: absolute;
left: 12px;
top: -60px;
background: var(--ink);
color: #fff;
padding: 10px 16px;
border-radius: var(--r-pill);
font-weight: 700;
z-index: 30;
transition: top 0.2s ease;
}
.skip-link:focus { top: 12px; }
.page {
width: 100%;
max-width: 720px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* ---------- Header ---------- */
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--card);
border: 3px solid #fff;
border-radius: var(--r);
box-shadow: var(--shadow-soft);
padding: 14px 18px;
}
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.brand-mark {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 18px;
background: linear-gradient(150deg, #fff3df, #ffe6c7);
box-shadow: inset 0 0 0 2px rgba(255, 138, 61, 0.25);
flex: none;
}
.brand-kicker {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--primary-deep);
}
.brand-title {
margin: 2px 0 0;
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.15rem, 4.4vw, 1.6rem);
line-height: 1.1;
color: var(--ink);
}
/* Dyslexia toggle */
.dys-toggle {
display: inline-flex;
align-items: center;
gap: 9px;
cursor: pointer;
flex: none;
padding: 8px 6px;
border-radius: var(--r-pill);
}
.dys-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
.dys-track {
width: 50px;
height: 30px;
border-radius: var(--r-pill);
background: #e7e2f4;
box-shadow: inset 0 0 0 2px rgba(44, 35, 80, 0.08);
display: inline-flex;
align-items: center;
padding: 3px;
transition: background 0.2s ease;
flex: none;
}
.dys-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 5px rgba(44, 35, 80, 0.3);
transition: transform 0.22s cubic-bezier(.34,1.56,.64,1);
}
.dys-toggle input:checked + .dys-track { background: var(--green); }
.dys-toggle input:checked + .dys-track .dys-thumb { transform: translateX(20px); }
.dys-toggle input:focus-visible + .dys-track { box-shadow: var(--ring); }
.dys-label {
font-weight: 700;
font-size: 0.85rem;
color: var(--ink-soft);
}
/* ---------- Reader ---------- */
.reader { display: flex; flex-direction: column; gap: 16px; outline: none; }
.scene {
border-radius: var(--r);
overflow: hidden;
box-shadow: var(--shadow);
border: 4px solid #fff;
}
.scene-art { position: relative; line-height: 0; }
.scene-svg { display: block; width: 100%; height: auto; }
.scene-badge {
position: absolute;
left: 14px;
bottom: 14px;
background: rgba(44, 35, 80, 0.78);
color: #fff;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.82rem;
padding: 7px 14px;
border-radius: var(--r-pill);
line-height: 1.2;
backdrop-filter: blur(3px);
}
/* gentle scene motion */
.moon { animation: bob 6s ease-in-out infinite; transform-origin: 360px 78px; }
.stars circle { animation: twinkle 3.2s ease-in-out infinite; }
.stars circle:nth-child(2n) { animation-delay: 0.8s; }
.stars circle:nth-child(3n) { animation-delay: 1.6s; }
.fox { animation: bob 5s ease-in-out infinite; transform-origin: 240px 248px; }
.scene-art.reading .fox { animation: wiggle 1.4s ease-in-out infinite; }
.mitten { animation: pulse-mitten 2.4s ease-in-out infinite; transform-origin: 206px 244px; }
@keyframes bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
@keyframes twinkle { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
@keyframes wiggle { 0%,100% { transform: rotate(-2deg); } 50% { transform: rotate(2deg); } }
@keyframes pulse-mitten { 0%,100% { transform: scale(1); } 50% { transform: scale(1.12); } }
/* ---------- Story card ---------- */
.story-card {
background: var(--card);
border-radius: var(--r);
border: 4px solid #fff;
box-shadow: var(--shadow-soft);
padding: 20px 22px 18px;
}
.story-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 0 0 14px;
flex-wrap: wrap;
}
.word-progress {
font-family: var(--font-display);
font-weight: 600;
font-size: 0.8rem;
color: var(--ink-soft);
background: #f3f0fb;
padding: 5px 12px;
border-radius: var(--r-pill);
}
.reading-state {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 700;
font-size: 0.82rem;
color: var(--secondary);
}
.reading-state::before {
content: "";
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--green);
}
.reading-state.is-playing::before { animation: blink 1s steps(2) infinite; background: var(--primary); }
.reading-state.is-done::before { background: var(--accent); }
@keyframes blink { 50% { opacity: 0.25; } }
.story-text {
font-size: clamp(1.18rem, 4.6vw, 1.5rem);
line-height: var(--line-h);
letter-spacing: var(--letter);
font-weight: 600;
color: var(--ink);
word-spacing: var(--word-gap);
}
.word {
display: inline;
cursor: pointer;
border-radius: 8px;
padding: 0.04em 0.1em;
margin: 0 -0.02em;
transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.word:hover { background: #eef9fb; }
.word:focus-visible { outline: none; box-shadow: var(--ring); }
.word.spoken { color: var(--ink-soft); }
.word.active {
background: linear-gradient(180deg, var(--highlight), var(--highlight-edge));
color: #5a3c00;
box-shadow: 0 3px 0 rgba(240, 116, 31, 0.25);
transform: translateY(-1px);
font-weight: 800;
}
.tap-hint {
margin: 16px 0 0;
font-size: 0.82rem;
font-weight: 700;
color: var(--ink-soft);
display: flex;
align-items: center;
gap: 6px;
}
.tap-hint::before { content: "👆"; }
/* ---------- Controls ---------- */
.controls {
position: sticky;
bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
background: var(--card);
border: 4px solid #fff;
border-radius: var(--r);
box-shadow: var(--shadow);
padding: 14px 18px;
}
.transport { display: flex; align-items: center; gap: 12px; }
.btn {
border: none;
font-family: var(--font-display);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
transition: transform 0.12s ease, box-shadow 0.15s ease, background 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.btn:focus-visible { outline: none; box-shadow: var(--ring); }
.btn:active { transform: scale(0.94); }
.btn-icon {
width: 52px;
height: 52px;
border-radius: 50%;
background: #f3f0fb;
color: var(--purple);
box-shadow: inset 0 0 0 2px rgba(139, 111, 214, 0.18);
}
.btn-icon:hover { background: #ece6fb; }
.btn-play {
min-height: 56px;
padding: 0 24px 0 20px;
border-radius: var(--r-pill);
background: linear-gradient(150deg, var(--primary), var(--primary-deep));
color: #fff;
font-weight: 800;
font-size: 1.05rem;
box-shadow: 0 8px 0 rgba(240, 116, 31, 0.35), var(--shadow-soft);
}
.btn-play:hover { transform: translateY(-2px); }
.btn-play:active { transform: translateY(2px); box-shadow: 0 3px 0 rgba(240, 116, 31, 0.35); }
.play-icon { display: inline-grid; place-items: center; width: 28px; height: 28px; }
.play-icon svg { grid-area: 1 / 1; }
.ico-pause { display: none; }
.btn-play[aria-pressed="true"] .ico-play { display: none; }
.btn-play[aria-pressed="true"] .ico-pause { display: block; }
.ico-muted { display: none; }
.btn-icon[aria-pressed="true"] .ico-sound { display: none; }
.btn-icon[aria-pressed="true"] .ico-muted { display: block; }
.btn-icon[aria-pressed="true"] { background: #ffe6ee; color: var(--pink); box-shadow: inset 0 0 0 2px rgba(255, 111, 156, 0.25); }
/* Speed slider */
.speed { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 200px; }
.speed-label {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.92rem;
color: var(--ink-soft);
}
#speedRange {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 12px;
border-radius: var(--r-pill);
background: linear-gradient(90deg, var(--secondary), var(--green));
cursor: pointer;
outline: none;
}
#speedRange::-webkit-slider-thumb {
-webkit-appearance: none;
width: 28px;
height: 28px;
border-radius: 50%;
background: #fff;
border: 4px solid var(--secondary);
box-shadow: 0 3px 8px rgba(44, 35, 80, 0.3);
cursor: pointer;
}
#speedRange::-moz-range-thumb {
width: 28px;
height: 28px;
border-radius: 50%;
background: #fff;
border: 4px solid var(--secondary);
box-shadow: 0 3px 8px rgba(44, 35, 80, 0.3);
cursor: pointer;
}
#speedRange:focus-visible { box-shadow: var(--ring); }
.speed-val {
font-family: var(--font-display);
font-weight: 800;
font-size: 1rem;
color: var(--ink);
min-width: 46px;
text-align: right;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 24px);
background: var(--ink);
color: #fff;
font-weight: 700;
font-size: 0.92rem;
padding: 12px 20px;
border-radius: var(--r-pill);
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s cubic-bezier(.34,1.56,.64,1);
z-index: 40;
max-width: calc(100% - 36px);
text-align: center;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
body { padding: 12px; }
.top { padding: 12px 14px; }
.dys-label { display: none; }
.story-card { padding: 16px 16px 14px; }
.controls { padding: 12px 14px; gap: 12px; }
.btn-play { flex: 1; }
.speed { width: 100%; order: 3; }
}
@media (max-width: 360px) {
.play-text { font-size: 0.95rem; }
.btn-icon { width: 48px; height: 48px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}(function () {
"use strict";
// ---- Story content -------------------------------------------------------
// Each sentence carries a scene label so the illustration caption can change.
var STORY = [
{ text: "One frosty night the little fox climbed the tallest hill.", scene: "The snowy hill" },
{ text: "Far above, the round moon shivered in the cold dark sky.", scene: "Under the moon" },
{ text: "“Brrr,” whispered the moon, “I have lost my fuzzy mitten!”", scene: "The moon speaks" },
{ text: "So the brave fox searched the snow, the pines, and the silver pond.", scene: "The search" },
{ text: "At last, tucked in a tree, she found one warm pink mitten.", scene: "Found it!" },
{ text: "The fox tossed it up, the moon smiled, and the whole sky glowed.", scene: "A glowing sky" }
];
// ---- Build word spans ----------------------------------------------------
var storyEl = document.getElementById("storyText");
var words = []; // { el, sentence, base } ms-per-word base duration tuning
STORY.forEach(function (sentence, si) {
var p = document.createElement("p");
p.className = "story-line";
p.style.margin = si === 0 ? "0" : "0.55em 0 0";
var tokens = sentence.text.split(/(\s+)/);
tokens.forEach(function (tok) {
if (/^\s+$/.test(tok)) {
p.appendChild(document.createTextNode(" "));
return;
}
if (!tok) return;
var span = document.createElement("span");
span.className = "word";
span.textContent = tok;
span.setAttribute("role", "button");
span.tabIndex = 0;
span.setAttribute("aria-label", "Read from “" + tok.replace(/[“”.,!?]/g, "") + "”");
var idx = words.length;
span.dataset.idx = String(idx);
words.push({ el: span, scene: sentence.scene, sceneIdx: si, raw: tok });
p.appendChild(span);
});
storyEl.appendChild(p);
});
// ---- Elements ------------------------------------------------------------
var playBtn = document.getElementById("playBtn");
var playText = document.getElementById("playText");
var restartBtn = document.getElementById("restartBtn");
var muteBtn = document.getElementById("muteBtn");
var speedRange = document.getElementById("speedRange");
var speedVal = document.getElementById("speedVal");
var progressEl = document.getElementById("wordProgress");
var stateEl = document.getElementById("readingState");
var sceneBadge = document.getElementById("sceneBadge");
var sceneArt = document.getElementById("sceneArt");
var toastEl = document.getElementById("toast");
// ---- State ---------------------------------------------------------------
var current = -1;
var playing = false;
var muted = false;
var rate = 1;
var timer = null;
var prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var speech = ("speechSynthesis" in window) ? window.speechSynthesis : null;
progressEl.textContent = "Word 0 of " + words.length;
// ---- Toast helper --------------------------------------------------------
var toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
// ---- Word timing ---------------------------------------------------------
// Base ~340ms/word, longer for long words, a beat after punctuation.
function wordDuration(i) {
var w = words[i].raw;
var len = w.replace(/[“”.,!?;:]/g, "").length;
var ms = 250 + len * 38;
if (/[.,!?;:”]$/.test(w)) ms += 260;
return ms / rate;
}
// ---- Highlight a word ----------------------------------------------------
function paint(i) {
if (current >= 0 && words[current]) {
words[current].el.classList.remove("active");
}
for (var k = 0; k < words.length; k++) {
words[k].el.classList.toggle("spoken", k < i);
}
current = i;
if (i >= 0 && i < words.length) {
var word = words[i];
word.el.classList.add("active");
word.el.classList.remove("spoken");
progressEl.textContent = "Word " + (i + 1) + " of " + words.length;
sceneBadge.textContent = "Page " + (word.sceneIdx + 1) + " · " + word.scene;
scrollIntoView(word.el);
}
}
function scrollIntoView(el) {
var r = el.getBoundingClientRect();
var margin = 120;
if (r.top < margin || r.bottom > window.innerHeight - margin) {
el.scrollIntoView({
behavior: prefersReduced ? "auto" : "smooth",
block: "center"
});
}
}
// ---- Narration engine ----------------------------------------------------
// Uses Web Speech API per-word when available + un-muted; otherwise a timer.
// The visual highlight is always timer-driven so it stays in sync and the
// demo works with no audio at all.
function speakWord(i) {
if (speech && !muted) {
try {
var u = new SpeechSynthesisUtterance(words[i].raw.replace(/[“”]/g, ""));
u.rate = Math.min(2, Math.max(0.5, rate));
u.pitch = 1.15;
u.volume = 1;
speech.speak(u);
} catch (e) { /* ignore voice errors, keep visual sync */ }
}
}
function step() {
if (!playing) return;
var next = current + 1;
if (next >= words.length) {
finish();
return;
}
paint(next);
speakWord(next);
timer = setTimeout(step, wordDuration(next));
}
function play(fromRestart) {
if (playing) return;
if (current >= words.length - 1 || current < 0) {
if (fromRestart || current >= words.length - 1) current = -1;
}
playing = true;
updatePlayUI(true);
setState("playing", "Reading…");
sceneArt.classList.add("reading");
if (speech && !muted) {
try { speech.cancel(); } catch (e) {}
}
// If we are mid-word, re-speak current; else advance.
if (current < 0) {
step();
} else {
paint(current);
speakWord(current);
timer = setTimeout(step, wordDuration(current));
}
}
function pause() {
if (!playing) return;
playing = false;
clearTimeout(timer);
if (speech) { try { speech.cancel(); } catch (e) {} }
updatePlayUI(false);
setState("paused", "Paused");
sceneArt.classList.remove("reading");
}
function finish() {
playing = false;
clearTimeout(timer);
if (words[current]) words[current].el.classList.remove("active");
for (var k = 0; k < words.length; k++) words[k].el.classList.add("spoken");
current = words.length - 1;
progressEl.textContent = "Word " + words.length + " of " + words.length;
updatePlayUI(false);
setState("done", "The End ✨");
sceneArt.classList.remove("reading");
sceneBadge.textContent = "The End ✨";
toast("You read the whole story! 🦊");
}
function restart() {
clearTimeout(timer);
if (speech) { try { speech.cancel(); } catch (e) {} }
playing = false;
current = -1;
for (var k = 0; k < words.length; k++) {
words[k].el.classList.remove("active", "spoken");
}
progressEl.textContent = "Word 0 of " + words.length;
sceneBadge.textContent = "Page 1 · " + STORY[0].scene;
updatePlayUI(false);
setState("ready", "Back to the start");
sceneArt.classList.remove("reading");
storyEl.scrollIntoView({ behavior: prefersReduced ? "auto" : "smooth", block: "nearest" });
toast("Back to the beginning ⏮️");
}
function jumpTo(i) {
var wasPlaying = playing;
clearTimeout(timer);
if (speech) { try { speech.cancel(); } catch (e) {} }
paint(i);
if (wasPlaying) {
playing = true;
speakWord(i);
timer = setTimeout(step, wordDuration(i));
} else {
setState("paused", "Jumped to “" + words[i].raw.replace(/[“”.,!?]/g, "") + "”");
}
}
// ---- UI helpers ----------------------------------------------------------
function updatePlayUI(isPlaying) {
playBtn.setAttribute("aria-pressed", String(isPlaying));
playBtn.setAttribute("aria-label", isPlaying ? "Pause the story" : "Play the story");
playText.textContent = isPlaying ? "Pause" : (current >= words.length - 1 ? "Read again" : "Read to me");
}
function setState(kind, label) {
stateEl.classList.remove("is-playing", "is-done");
if (kind === "playing") stateEl.classList.add("is-playing");
if (kind === "done") stateEl.classList.add("is-done");
stateEl.textContent = label;
}
// ---- Events --------------------------------------------------------------
playBtn.addEventListener("click", function () {
if (playing) pause(); else play(false);
});
restartBtn.addEventListener("click", restart);
muteBtn.addEventListener("click", function () {
muted = !muted;
muteBtn.setAttribute("aria-pressed", String(muted));
muteBtn.setAttribute("aria-label", muted ? "Unmute narration voice" : "Mute narration voice");
if (muted && speech) { try { speech.cancel(); } catch (e) {} }
toast(muted ? "Voice off — words still light up 🔇" : "Voice on 🔊");
});
speedRange.addEventListener("input", function () {
rate = parseFloat(speedRange.value);
speedVal.textContent = rate.toFixed(1) + "×";
if (playing) {
// re-time the in-flight word with the new rate
clearTimeout(timer);
timer = setTimeout(step, wordDuration(current < 0 ? 0 : current));
}
});
// Word click / keyboard jump
storyEl.addEventListener("click", function (e) {
var span = e.target.closest(".word");
if (!span) return;
jumpTo(parseInt(span.dataset.idx, 10));
});
storyEl.addEventListener("keydown", function (e) {
var span = e.target.closest(".word");
if (!span) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
jumpTo(parseInt(span.dataset.idx, 10));
}
});
// Global keyboard shortcuts (ignore while typing in the slider etc.)
document.addEventListener("keydown", function (e) {
var tag = (e.target.tagName || "").toLowerCase();
if (e.target.classList && e.target.classList.contains("word")) return;
if (tag === "input") return;
if (e.code === "Space") {
e.preventDefault();
if (playing) pause(); else play(false);
} else if (e.key === "ArrowRight") {
e.preventDefault();
jumpTo(Math.min(words.length - 1, current + 1));
} else if (e.key === "ArrowLeft") {
e.preventDefault();
jumpTo(Math.max(0, current < 0 ? 0 : current - 1));
} else if (e.key.toLowerCase() === "r") {
restart();
}
});
// Dyslexia-friendly toggle
var dysToggle = document.getElementById("dysToggle");
dysToggle.addEventListener("change", function () {
document.body.classList.toggle("dyslexia", dysToggle.checked);
toast(dysToggle.checked ? "Easy-read font on 📖" : "Easy-read font off");
});
// Pause cleanly if the tab is hidden so speech doesn't run on in background.
document.addEventListener("visibilitychange", function () {
if (document.hidden && playing) pause();
});
// Cancel any queued speech on unload (some browsers keep it alive).
window.addEventListener("beforeunload", function () {
if (speech) { try { speech.cancel(); } catch (e) {} }
});
// Initial caption
sceneBadge.textContent = "Page 1 · " + STORY[0].scene;
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Storybook — Read-Along</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;0,800;1,600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#story">Skip to the story</a>
<div class="page">
<header class="top" role="banner">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 48 48" width="40" height="40">
<rect x="6" y="8" width="16" height="32" rx="3" fill="#ff8a3d"/>
<rect x="24" y="8" width="18" height="32" rx="3" fill="#5ec5d6"/>
<path d="M24 8v32" stroke="#fff8ef" stroke-width="2.5"/>
<circle cx="14" cy="18" r="3" fill="#ffd23f"/>
<path d="M10 28h8M10 32h6" stroke="#fff8ef" stroke-width="2" stroke-linecap="round"/>
<path d="M28 18h10M28 23h10M28 28h7" stroke="#fff8ef" stroke-width="2" stroke-linecap="round"/>
</svg>
</span>
<div class="brand-text">
<p class="brand-kicker">Lantern Tales</p>
<h1 class="brand-title">The Moon's Lost Mitten</h1>
</div>
</div>
<label class="dys-toggle" title="Easier-to-read font & spacing">
<input type="checkbox" id="dysToggle" />
<span class="dys-track" aria-hidden="true"><span class="dys-thumb"></span></span>
<span class="dys-label">Easy-read</span>
</label>
</header>
<main id="story" class="reader" tabindex="-1">
<!-- Illustration scene -->
<section class="scene" aria-label="Story illustration">
<div class="scene-art" id="sceneArt">
<svg viewBox="0 0 480 280" class="scene-svg" role="img" aria-label="A small fox on a snowy hill looks up at the moon.">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#3a2a6b"/>
<stop offset="0.6" stop-color="#6a4fb0"/>
<stop offset="1" stop-color="#b78bd9"/>
</linearGradient>
<radialGradient id="glow" cx="50%" cy="50%" r="50%">
<stop offset="0" stop-color="#fff5cc" stop-opacity="0.95"/>
<stop offset="1" stop-color="#fff5cc" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="480" height="280" fill="url(#sky)"/>
<circle cx="360" cy="78" r="70" fill="url(#glow)"/>
<circle class="moon" cx="360" cy="78" r="34" fill="#ffd23f"/>
<circle cx="348" cy="70" r="6" fill="#f2bf2f"/>
<circle cx="372" cy="86" r="4" fill="#f2bf2f"/>
<g class="stars" fill="#fff5cc">
<circle cx="60" cy="40" r="2.5"/>
<circle cx="120" cy="70" r="1.8"/>
<circle cx="200" cy="34" r="2.2"/>
<circle cx="430" cy="150" r="2"/>
<circle cx="90" cy="120" r="1.6"/>
<circle cx="260" cy="90" r="1.8"/>
</g>
<!-- snowy hills -->
<path d="M0 220 Q120 180 240 215 T480 205 V280 H0 Z" fill="#e9eefc"/>
<path d="M0 250 Q160 220 320 250 T480 245 V280 H0 Z" fill="#cfd8f5"/>
<!-- pine trees -->
<g fill="#7bd389">
<path d="M70 235 l16 -34 l16 34 z"/>
<path d="M78 220 l8 -22 l8 22 z"/>
<path d="M410 240 l14 -28 l14 28 z"/>
</g>
<!-- fox -->
<g class="fox">
<ellipse cx="240" cy="248" rx="34" ry="10" fill="#c97a2e" opacity="0.25"/>
<path d="M210 246 q-6 -34 30 -36 q40 2 32 34 q-30 10 -62 2 z" fill="#ff8a3d"/>
<path d="M222 214 q18 -10 36 0 q-2 -10 -8 -14 l-6 8 l-6 -8 q-12 8 -16 14 z" fill="#ff8a3d"/>
<path d="M228 210 l4 -8 l4 8 z M252 210 l4 -8 l4 8 z" fill="#ffd6b0"/>
<path d="M268 240 q26 -2 20 18 q-12 6 -22 -4 z" fill="#ff8a3d"/>
<path d="M280 250 q8 4 6 12" stroke="#fff8ef" stroke-width="3" fill="none" stroke-linecap="round"/>
<circle cx="232" cy="226" r="2.6" fill="#2c2350"/>
<circle cx="250" cy="226" r="2.6" fill="#2c2350"/>
<path d="M238 232 q4 4 8 0" stroke="#2c2350" stroke-width="2" fill="none" stroke-linecap="round"/>
<ellipse class="mitten" cx="206" cy="244" rx="9" ry="11" fill="#ff6f9c"/>
<rect x="199" y="248" width="14" height="6" rx="3" fill="#fff8ef"/>
</g>
</svg>
<span class="scene-badge" id="sceneBadge">Page 1 · The snowy hill</span>
</div>
</section>
<!-- Story text -->
<section class="story-card" aria-label="Read-along text">
<p class="story-meta">
<span class="word-progress" id="wordProgress" aria-live="off">Word 0 of 0</span>
<span class="reading-state" id="readingState" role="status">Ready when you are</span>
</p>
<div class="story-text" id="storyText" lang="en">
<!-- words injected by script.js -->
</div>
<p class="tap-hint">Tip: tap any word to jump the narrator there.</p>
</section>
</main>
<!-- Controls -->
<nav class="controls" aria-label="Playback controls">
<div class="transport">
<button class="btn btn-icon" id="restartBtn" type="button" aria-label="Restart from the beginning">
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path d="M12 5V2L7 6l5 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z" fill="currentColor"/></svg>
</button>
<button class="btn btn-play" id="playBtn" type="button" aria-pressed="false" aria-label="Play the story">
<span class="play-icon" aria-hidden="true">
<svg class="ico-play" viewBox="0 0 24 24" width="28" height="28"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
<svg class="ico-pause" viewBox="0 0 24 24" width="28" height="28"><path d="M7 5h4v14H7zM13 5h4v14h-4z" fill="currentColor"/></svg>
</span>
<span class="play-text" id="playText">Read to me</span>
</button>
<button class="btn btn-icon" id="muteBtn" type="button" aria-pressed="false" aria-label="Mute narration voice">
<svg class="ico-sound" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path d="M4 9v6h4l5 5V4L8 9H4z" fill="currentColor"/><path d="M16 8a5 5 0 0 1 0 8" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>
<svg class="ico-muted" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path d="M4 9v6h4l5 5V4L8 9H4z" fill="currentColor"/><path d="M17 9l5 6M22 9l-5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
<div class="speed">
<label for="speedRange" class="speed-label">Speed</label>
<input type="range" id="speedRange" min="0.5" max="1.8" step="0.1" value="1" aria-describedby="speedVal" />
<output id="speedVal" class="speed-val">1.0×</output>
</div>
</nav>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Read-Along
A warm, picture-book read-along for the fictional bedtime story The Moon’s Lost Mitten. An inline-SVG illustration panel sits above the text — a little fox on a moonlit snowy hill, with a softly bobbing moon, twinkling stars, and a pulsing pink mitten, all drawn in CSS with no external images. Below it, the story is rendered word by word in a high-legibility friendly font, with a live word counter and a status pill that breathes while the narrator reads.
Press Read to me and the words highlight in karaoke sequence, gently scrolling to keep the active word centered, while the scene caption updates as each page of the story begins. When supported, the browser’s Web Speech API voices each word; otherwise a tuned timer keeps everything in sync so the demo works with no audio at all. Restart returns to the very start, the speaker button mutes the voice (words still light up), and a speed slider stretches the pace from a slow 0.5x crawl to a brisk 1.8x.
Every word is a real control: tap or press Enter on any word to jump the narration there. Keyboard shortcuts cover the rest — Space plays and pauses, the arrow keys step word by word, and R restarts. An easy-read toggle switches to a dyslexia-friendly font with looser letter and word spacing, focus-visible rings keep it keyboard-friendly, reduced motion is respected, and a toast() helper surfaces friendly hints. The layout collapses gracefully down to ~360px.
Illustrative kids’ UI only — fictional stories, characters, and audio.