Storybook — Tap-to-animate Scene
A cheerful tap-to-play picture-book scene built entirely from inline SVG, where children explore Sunny Meadow by waking five friendly characters. Tap the sun, a drifting cloud, a hopping bird, a tail-wagging puppy, or the cottage door and each one pops with its own keyframe animation, a sound-word speech bubble, sparkle particles, and a soft chime. A find-all-5 counter, progress pips, story log, easy-read font toggle, celebration overlay, full keyboard support, and reduced-motion respect round it out.
MCP
Code
:root {
--bg: #fff8ef;
--surface: #ffffff;
--ink: #2c2350;
--ink-soft: #6a6391;
--primary: #ff8a3d;
--primary-deep: #e0682a;
--secondary: #5ec5d6;
--accent: #ffd23f;
--pink: #ff6f9c;
--green: #7bd389;
--purple: #b78bff;
--r: 22px;
--r-lg: 30px;
--r-pill: 999px;
--shadow-soft: 0 10px 26px rgba(44, 35, 80, 0.12);
--shadow-pop: 0 6px 0 rgba(44, 35, 80, 0.18);
--border: 3px solid #2c2350;
--font-display: "Baloo 2", system-ui, sans-serif;
--font-body: "Nunito", system-ui, -apple-system, sans-serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--font-body);
background:
radial-gradient(1200px 540px at 80% -10%, #ffe9cf 0%, transparent 60%),
radial-gradient(900px 480px at 0% 110%, #d9f3ff 0%, transparent 55%),
var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
/* dyslexia-friendly mode */
body.easy-read {
--font-body: "Nunito", system-ui, sans-serif;
letter-spacing: 0.035em;
word-spacing: 0.12em;
line-height: 1.7;
}
body.easy-read .scene-heading,
body.easy-read .found-list {
letter-spacing: 0.04em;
}
.skip-link {
position: absolute;
left: 12px;
top: -60px;
background: var(--ink);
color: #fff;
padding: 10px 16px;
border-radius: var(--r-pill);
z-index: 50;
transition: top 0.2s ease;
font-weight: 700;
}
.skip-link:focus {
top: 12px;
}
.page {
max-width: 1120px;
margin: 0 auto;
padding: clamp(14px, 3vw, 30px);
}
/* ---------- top bar ---------- */
.topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 14px;
background: var(--surface);
border: var(--border);
border-radius: var(--r-lg);
padding: 12px 16px;
box-shadow: var(--shadow-soft);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.brand-badge {
flex: none;
width: 46px;
height: 46px;
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--accent), var(--primary));
border: var(--border);
border-radius: 16px;
box-shadow: var(--shadow-pop);
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.brand-text strong {
font-family: var(--font-display);
font-size: 1.3rem;
font-weight: 800;
}
.brand-text span {
color: var(--ink-soft);
font-size: 0.82rem;
font-weight: 600;
}
.topbar-tools {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* toggle switches */
.switch {
display: inline-flex;
align-items: center;
gap: 9px;
cursor: pointer;
user-select: none;
background: var(--bg);
border: 2px solid #efe2cf;
border-radius: var(--r-pill);
padding: 7px 12px 7px 14px;
font-weight: 700;
font-size: 0.86rem;
min-height: 44px;
}
.switch-label {
color: var(--ink);
}
.switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.switch-track {
position: relative;
width: 44px;
height: 26px;
border-radius: var(--r-pill);
background: #d9d2e6;
transition: background 0.2s ease;
flex: none;
}
.switch-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 4px rgba(44, 35, 80, 0.3);
transition: transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.switch input:checked + .switch-track {
background: var(--green);
}
.switch input:checked + .switch-track .switch-thumb {
transform: translateX(18px);
}
.switch input:focus-visible + .switch-track {
outline: 3px solid var(--secondary);
outline-offset: 2px;
}
/* ---------- layout ---------- */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 290px;
gap: 20px;
margin-top: 20px;
align-items: start;
}
.scene-heading {
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.4rem, 4.4vw, 2.1rem);
margin: 0 0 14px;
text-align: center;
color: var(--ink);
}
.scene-heading em {
font-style: normal;
color: var(--primary-deep);
background: linear-gradient(transparent 62%, #ffe39b 0);
padding: 0 4px;
}
/* ---------- scene frame ---------- */
.scene-frame {
position: relative;
border: 4px solid var(--ink);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: var(--shadow-soft);
background: #e8f8ff;
aspect-ratio: 800 / 520;
}
.scene-svg {
display: block;
width: 100%;
height: 100%;
}
/* hotspots */
.hotspot {
cursor: pointer;
outline: none;
transform-box: fill-box;
}
.hotspot .hs-art {
transition: transform 0.18s ease;
transform-box: fill-box;
transform-origin: center;
}
.hotspot:hover .hs-art {
transform: scale(1.05);
}
.hotspot:focus-visible .hit {
stroke: var(--secondary);
stroke-width: 5;
stroke-dasharray: 9 7;
fill: rgba(94, 197, 214, 0.12);
}
.hotspot.found .hit {
stroke: var(--green);
stroke-width: 4;
stroke-dasharray: none;
}
/* sun rays */
.sun-rays {
transform-box: fill-box;
transform-origin: 680px 96px;
}
.hotspot.spin .sun-art .sun-rays {
animation: ray-spin 0.9s ease;
}
@keyframes ray-spin {
to {
transform: rotate(60deg);
}
}
/* per-hotspot play animations */
.hotspot.play.sun .hs-art {
animation: sun-pop 0.7s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes sun-pop {
0% { transform: scale(1); }
40% { transform: scale(1.22); }
100% { transform: scale(1); }
}
.hotspot.play.cloud .hs-art {
animation: cloud-drift 0.85s ease-in-out;
}
@keyframes cloud-drift {
0% { transform: translateX(0); }
50% { transform: translateX(34px); }
100% { transform: translateX(0); }
}
.bird-art {
transform-box: fill-box;
transform-origin: center;
}
.hotspot.play.bird .hs-art {
animation: bird-hop 0.8s ease;
}
@keyframes bird-hop {
0% { transform: translateY(0) rotate(0); }
30% { transform: translateY(-40px) rotate(-8deg); }
60% { transform: translateY(-18px) rotate(4deg); }
100% { transform: translateY(0) rotate(0); }
}
.bird-art .wing {
transform-box: fill-box;
transform-origin: 492px 192px;
}
.hotspot.play.bird .bird-art .wing {
animation: flap 0.2s ease-in-out 3;
}
@keyframes flap {
50% { transform: rotate(-26deg); }
}
.dog-head {
transform-box: fill-box;
transform-origin: 156px 420px;
}
.hotspot.play.dog .dog-head {
animation: dog-nod 0.7s ease;
}
@keyframes dog-nod {
0% { transform: rotate(0); }
25% { transform: rotate(-9deg); }
60% { transform: rotate(7deg); }
100% { transform: rotate(0); }
}
.dog-art .tail {
transform-box: fill-box;
transform-origin: 256px 392px;
}
.hotspot.play.dog .dog-art .tail {
animation: wag 0.18s ease-in-out 4;
}
@keyframes wag {
50% { transform: rotate(22deg); }
}
.door-art {
transform-box: fill-box;
transform-origin: 654px 396px;
}
.hotspot.play.door .door-art {
animation: door-open 0.9s ease;
}
@keyframes door-open {
0% { transform: perspective(300px) rotateY(0); }
55% { transform: perspective(300px) rotateY(-62deg); }
100% { transform: perspective(300px) rotateY(0); }
}
/* ---------- FX layer: bubbles + sparkles ---------- */
.fx-layer {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.bubble {
position: absolute;
transform: translate(-50%, -50%) scale(0.4);
background: #fff;
color: var(--ink);
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(0.95rem, 2.6vw, 1.4rem);
padding: 8px 16px;
border: 3px solid var(--ink);
border-radius: var(--r-pill);
white-space: nowrap;
box-shadow: var(--shadow-pop);
animation: bubble-pop 1.15s ease forwards;
}
.bubble::after {
content: "";
position: absolute;
bottom: -11px;
left: 22px;
width: 16px;
height: 16px;
background: #fff;
border-right: 3px solid var(--ink);
border-bottom: 3px solid var(--ink);
transform: rotate(45deg);
}
@keyframes bubble-pop {
0% { transform: translate(-50%, -50%) scale(0.3); opacity: 0; }
18% { transform: translate(-50%, -150%) scale(1.08); opacity: 1; }
70% { transform: translate(-50%, -180%) scale(1); opacity: 1; }
100% { transform: translate(-50%, -260%) scale(0.92); opacity: 0; }
}
.spark {
position: absolute;
width: 12px;
height: 12px;
border-radius: 2px;
transform: translate(-50%, -50%);
animation: spark-fly 0.7s ease-out forwards;
}
@keyframes spark-fly {
0% { opacity: 1; transform: translate(-50%, -50%) scale(1) rotate(0); }
100% {
opacity: 0;
transform:
translate(calc(-50% + var(--dx)), calc(-50% + var(--dy)))
scale(0.2) rotate(180deg);
}
}
/* ---------- side panel ---------- */
.panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.counter-card {
background: linear-gradient(160deg, #fff 0%, #fff5e6 100%);
border: var(--border);
border-radius: var(--r-lg);
padding: 18px;
text-align: center;
box-shadow: var(--shadow-soft);
}
.counter-kicker {
margin: 0;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.74rem;
color: var(--ink-soft);
}
.counter-num {
margin: 4px 0 12px;
font-family: var(--font-display);
font-size: 2.8rem;
font-weight: 800;
line-height: 1;
color: var(--primary-deep);
}
.counter-num.bump {
animation: num-bump 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes num-bump {
50% { transform: scale(1.28); }
}
.counter-of {
font-size: 1.4rem;
color: var(--ink-soft);
}
.pips {
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.pip {
width: 42px;
height: 42px;
display: grid;
place-items: center;
font-size: 1.2rem;
background: #fff;
border: 2px dashed #e3c79c;
border-radius: 14px;
filter: grayscale(1) opacity(0.45);
transition: transform 0.25s ease, filter 0.25s ease, border-color 0.25s ease;
}
.pip.on {
filter: none;
border: 2px solid var(--green);
background: #effbf0;
transform: scale(1.06) rotate(-4deg);
}
.found-list-card {
background: var(--surface);
border: var(--border);
border-radius: var(--r-lg);
padding: 16px 18px;
box-shadow: var(--shadow-soft);
}
.panel-title {
margin: 0 0 10px;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.05rem;
}
.found-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 92px;
}
.found-list li {
display: flex;
align-items: center;
gap: 9px;
background: var(--bg);
border-radius: 14px;
padding: 8px 12px;
font-weight: 700;
font-size: 0.9rem;
}
.found-list li.found-empty {
background: transparent;
color: var(--ink-soft);
font-weight: 600;
font-style: italic;
}
.found-list li .li-emoji {
font-size: 1.2rem;
}
.found-list li.added {
animation: row-in 0.4s ease;
}
@keyframes row-in {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}
.btn-reset,
.btn-again,
.btn-pip {
font-family: var(--font-display);
font-weight: 700;
cursor: pointer;
}
.btn-reset {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 50px;
border: var(--border);
border-radius: var(--r-pill);
background: var(--accent);
color: var(--ink);
font-size: 1rem;
box-shadow: var(--shadow-pop);
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.btn-reset:hover {
background: #ffdc63;
}
.btn-reset:active {
transform: translateY(4px);
box-shadow: 0 2px 0 rgba(44, 35, 80, 0.18);
}
.btn-reset:focus-visible,
.btn-again:focus-visible {
outline: 3px solid var(--secondary);
outline-offset: 3px;
}
.hint {
margin: 12px 0 0;
text-align: center;
color: var(--ink-soft);
font-weight: 600;
font-size: 0.9rem;
}
kbd {
font-family: var(--font-body);
font-weight: 800;
background: #fff;
border: 2px solid #e3c79c;
border-bottom-width: 3px;
border-radius: 8px;
padding: 1px 7px;
font-size: 0.82rem;
}
/* ---------- celebration overlay ---------- */
.cheer {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: rgba(44, 35, 80, 0.45);
z-index: 40;
padding: 20px;
backdrop-filter: blur(2px);
}
.cheer[hidden] {
display: none;
}
.cheer-card {
background: var(--surface);
border: 4px solid var(--ink);
border-radius: var(--r-lg);
padding: 28px;
text-align: center;
max-width: 360px;
box-shadow: var(--shadow-soft);
animation: cheer-in 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes cheer-in {
from { transform: scale(0.7); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.cheer-emoji {
font-size: 3.2rem;
display: block;
animation: emoji-bounce 1s ease infinite;
}
@keyframes emoji-bounce {
0%, 100% { transform: translateY(0) rotate(-4deg); }
50% { transform: translateY(-12px) rotate(4deg); }
}
.cheer-card h2 {
font-family: var(--font-display);
margin: 8px 0 6px;
font-size: 1.6rem;
}
.cheer-card p {
margin: 0 0 18px;
color: var(--ink-soft);
font-weight: 600;
}
.btn-again {
min-height: 50px;
padding: 0 26px;
border: var(--border);
border-radius: var(--r-pill);
background: var(--primary);
color: #fff;
font-size: 1.05rem;
box-shadow: var(--shadow-pop);
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.btn-again:hover {
background: #ff9b56;
}
.btn-again:active {
transform: translateY(4px);
box-shadow: 0 2px 0 rgba(44, 35, 80, 0.18);
}
/* ---------- toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 130%);
background: var(--ink);
color: #fff;
font-weight: 700;
padding: 12px 20px;
border-radius: var(--r-pill);
box-shadow: var(--shadow-soft);
z-index: 60;
opacity: 0;
transition: transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
max-width: 86vw;
text-align: center;
}
.toast.show {
transform: translate(-50%, 0);
opacity: 1;
}
/* ---------- responsive ---------- */
@media (max-width: 880px) {
.layout {
grid-template-columns: 1fr;
}
.panel {
flex-direction: row;
flex-wrap: wrap;
}
.counter-card,
.found-list-card {
flex: 1 1 220px;
}
.btn-reset {
flex: 1 1 100%;
}
}
@media (max-width: 560px) {
.topbar {
justify-content: center;
text-align: center;
}
.topbar-tools {
width: 100%;
justify-content: center;
}
.panel {
flex-direction: column;
}
.switch-label {
font-size: 0.8rem;
}
}
@media (max-width: 380px) {
.pip {
width: 38px;
height: 38px;
}
}
/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
.hotspot.play .hs-art,
.bubble {
/* still give a small, instant visual confirmation */
opacity: 1;
}
.cheer-emoji {
animation: none;
}
}(function () {
"use strict";
var prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
/* ---- friendly metadata for each hotspot ---- */
var FRIENDS = {
sun: { emoji: "☀️", label: "The sun blinked and beamed.", color: "#ffd23f" },
cloud: { emoji: "☁️", label: "A fluffy cloud drifted by.", color: "#5ec5d6" },
bird: { emoji: "🐦", label: "The little bird hopped up high.", color: "#5ec5d6" },
dog: { emoji: "🐶", label: "The puppy wagged its tail.", color: "#caa06a" },
door: { emoji: "🚪", label: "The cottage door swung open.", color: "#ff8a3d" }
};
var TOTAL = Object.keys(FRIENDS).length;
var found = Object.create(null); // name -> true
var foundCount = 0;
/* ---- elements ---- */
var fxLayer = document.getElementById("fxLayer");
var sceneFrame = document.getElementById("scene");
var countNow = document.getElementById("countNow");
var counterNum = countNow ? countNow.parentElement : null;
var pipsWrap = document.getElementById("pips");
var foundList = document.getElementById("foundList");
var resetBtn = document.getElementById("resetBtn");
var cheer = document.getElementById("cheer");
var againBtn = document.getElementById("againBtn");
var soundToggle = document.getElementById("sound");
var dyslexiaToggle = document.getElementById("dyslexia");
var toastEl = document.getElementById("toast");
var hotspots = Array.prototype.slice.call(
document.querySelectorAll(".hotspot")
);
/* ---- build sun rays once ---- */
(function buildRays() {
var rays = document.getElementById("sunRays");
if (!rays) return;
var SVGNS = "http://www.w3.org/2000/svg";
for (var i = 0; i < 8; i++) {
var r = document.createElementNS(SVGNS, "rect");
r.setAttribute("x", "676");
r.setAttribute("y", "20");
r.setAttribute("width", "8");
r.setAttribute("height", "24");
r.setAttribute("rx", "4");
r.setAttribute("fill", "#ffd23f");
r.setAttribute("transform", "rotate(" + i * 45 + " 680 96)");
rays.appendChild(r);
}
})();
/* ---- toast helper ---- */
var toastTimer = null;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("show");
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(function () {
toastEl.classList.remove("show");
}, 2000);
}
/* ---- tiny WebAudio "pop" so sound words feel alive (no asset files) ---- */
var audioCtx = null;
function beep(name) {
if (!soundToggle || !soundToggle.checked) return;
if (prefersReduced) return;
try {
if (!audioCtx) {
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
audioCtx = new AC();
}
if (audioCtx.state === "suspended") audioCtx.resume();
// pick a friendly note per friend
var notes = { sun: 660, cloud: 392, bird: 880, dog: 294, door: 523 };
var base = notes[name] || 523;
var osc = audioCtx.createOscillator();
var gain = audioCtx.createGain();
osc.type = "triangle";
osc.frequency.setValueAtTime(base, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(
base * 1.5,
audioCtx.currentTime + 0.12
);
gain.gain.setValueAtTime(0.0001, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.12, audioCtx.currentTime + 0.02);
gain.gain.exponentialRampToValueAtTime(
0.0001,
audioCtx.currentTime + 0.28
);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.3);
} catch (e) {
/* audio unavailable — interactions still work */
}
}
/* ---- map SVG coords to fx-layer pixel position ---- */
function bubbleAt(clientX, clientY, word) {
if (!fxLayer) return;
var rect = sceneFrame.getBoundingClientRect();
var x = clientX - rect.left;
var y = clientY - rect.top;
var b = document.createElement("div");
b.className = "bubble";
b.textContent = word;
b.style.left = x + "px";
b.style.top = y + "px";
fxLayer.appendChild(b);
window.setTimeout(function () {
if (b.parentNode) b.parentNode.removeChild(b);
}, 1300);
if (!prefersReduced) sparkles(x, y);
}
var SPARK_COLORS = ["#ff8a3d", "#5ec5d6", "#ffd23f", "#ff6f9c", "#7bd389", "#b78bff"];
function sparkles(x, y) {
var n = 9;
for (var i = 0; i < n; i++) {
var s = document.createElement("span");
s.className = "spark";
var angle = (Math.PI * 2 * i) / n + Math.random() * 0.4;
var dist = 36 + Math.random() * 34;
s.style.left = x + "px";
s.style.top = y + "px";
s.style.background = SPARK_COLORS[i % SPARK_COLORS.length];
s.style.setProperty("--dx", Math.cos(angle) * dist + "px");
s.style.setProperty("--dy", Math.sin(angle) * dist + "px");
fxLayer.appendChild(s);
(function (node) {
window.setTimeout(function () {
if (node.parentNode) node.parentNode.removeChild(node);
}, 750);
})(s);
}
}
/* ---- center of a hotspot's hit area, in client coords ---- */
function hotspotCenter(hs) {
var hit = hs.querySelector(".hit");
var target = hit || hs;
var r = target.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height * 0.32 };
}
/* ---- play a hotspot ---- */
function activate(hs, clientX, clientY) {
var name = hs.getAttribute("data-name");
var word = hs.getAttribute("data-word") || "Hooray!";
if (!name) return;
// animation
hs.classList.remove("play", "spin");
// force reflow so re-adding restarts the animation
void hs.offsetWidth;
hs.classList.add("play");
if (name === "sun") hs.classList.add("spin");
var dur = prefersReduced ? 50 : 950;
window.setTimeout(function () {
hs.classList.remove("play", "spin");
}, dur);
// bubble + sparkles
var pt =
typeof clientX === "number"
? { x: clientX, y: clientY }
: hotspotCenter(hs);
bubbleAt(pt.x, pt.y, word);
beep(name);
// discovery bookkeeping (counts once)
if (!found[name]) {
found[name] = true;
foundCount++;
hs.classList.add("found");
hs.setAttribute("aria-pressed", "true");
updateCount();
markPip(name);
addLogRow(name);
if (foundCount === TOTAL) {
window.setTimeout(celebrate, 600);
} else {
toast(word + " " + foundCount + " of " + TOTAL + " found");
}
} else {
toast(word);
}
}
function updateCount() {
if (!countNow) return;
countNow.textContent = String(foundCount);
if (counterNum && !prefersReduced) {
counterNum.classList.remove("bump");
void counterNum.offsetWidth;
counterNum.classList.add("bump");
}
}
function markPip(name) {
if (!pipsWrap) return;
var pip = pipsWrap.querySelector('[data-for="' + name + '"]');
if (pip) pip.classList.add("on");
}
function addLogRow(name) {
if (!foundList) return;
var empty = foundList.querySelector(".found-empty");
if (empty) empty.remove();
var info = FRIENDS[name];
var li = document.createElement("li");
li.className = "added";
var em = document.createElement("span");
em.className = "li-emoji";
em.setAttribute("aria-hidden", "true");
em.textContent = info.emoji;
var txt = document.createElement("span");
txt.textContent = info.label;
li.appendChild(em);
li.appendChild(txt);
foundList.appendChild(li);
}
function celebrate() {
if (cheer) {
cheer.hidden = false;
if (againBtn) againBtn.focus();
}
confettiBurst();
}
function confettiBurst() {
if (prefersReduced || !fxLayer) return;
var rect = sceneFrame.getBoundingClientRect();
for (var i = 0; i < 26; i++) {
sparkles(Math.random() * rect.width, Math.random() * rect.height * 0.7);
}
}
/* ---- reset ---- */
function reset() {
found = Object.create(null);
foundCount = 0;
hotspots.forEach(function (hs) {
hs.classList.remove("found", "play", "spin");
hs.removeAttribute("aria-pressed");
});
if (pipsWrap) {
Array.prototype.forEach.call(
pipsWrap.querySelectorAll(".pip"),
function (p) {
p.classList.remove("on");
}
);
}
if (foundList) {
foundList.innerHTML =
'<li class="found-empty">Tap a friend to start the story…</li>';
}
updateCount();
if (cheer) cheer.hidden = true;
toast("Fresh meadow! Find the 5 surprises.");
}
/* ---- wire hotspots (pointer + keyboard) ---- */
hotspots.forEach(function (hs) {
hs.addEventListener("click", function (e) {
activate(hs, e.clientX, e.clientY);
});
hs.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
e.preventDefault();
activate(hs); // centered bubble for keyboard users
}
});
});
if (resetBtn) resetBtn.addEventListener("click", reset);
if (againBtn) againBtn.addEventListener("click", reset);
// close celebration on Escape or backdrop click
if (cheer) {
cheer.addEventListener("click", function (e) {
if (e.target === cheer) cheer.hidden = true;
});
}
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && cheer && !cheer.hidden) cheer.hidden = true;
});
/* ---- toggles ---- */
if (dyslexiaToggle) {
dyslexiaToggle.addEventListener("change", function () {
document.body.classList.toggle("easy-read", dyslexiaToggle.checked);
toast(
dyslexiaToggle.checked
? "Easy-read font on"
: "Easy-read font off"
);
});
}
if (soundToggle) {
soundToggle.addEventListener("change", function () {
toast(soundToggle.checked ? "Sound words on 🔊" : "Sound words off 🔇");
});
}
// first run
updateCount();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Storybook — Tap-to-animate Scene</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="#scene">Skip to the scene</a>
<div class="page">
<header class="topbar">
<div class="brand">
<span class="brand-badge" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26">
<path
d="M5 4a2 2 0 0 1 2-2h6l6 6v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2z"
fill="#fff"
stroke="#2c2350"
stroke-width="1.6"
stroke-linejoin="round"
/>
<path d="M13 2v6h6" fill="none" stroke="#2c2350" stroke-width="1.6" stroke-linejoin="round" />
<path d="M8 12h8M8 15h6" stroke="#ff8a3d" stroke-width="1.8" stroke-linecap="round" />
</svg>
</span>
<div class="brand-text">
<strong>Sunny Meadow</strong>
<span>A tap-to-play picture book</span>
</div>
</div>
<div class="topbar-tools">
<label class="switch" for="dyslexia">
<span class="switch-label">Easy-read font</span>
<input type="checkbox" id="dyslexia" />
<span class="switch-track" aria-hidden="true"><span class="switch-thumb"></span></span>
</label>
<label class="switch" for="sound">
<span class="switch-label">Sound words</span>
<input type="checkbox" id="sound" checked />
<span class="switch-track" aria-hidden="true"><span class="switch-thumb"></span></span>
</label>
</div>
</header>
<main class="layout">
<section class="scene-wrap" aria-labelledby="scene-title">
<h1 id="scene-title" class="scene-heading">
Tap the meadow to find <em>5 surprises</em>!
</h1>
<div
class="scene-frame"
id="scene"
role="group"
aria-label="Interactive Sunny Meadow scene. Tap a character to make it move."
>
<svg
class="scene-svg"
viewBox="0 0 800 520"
role="img"
aria-label="A cartoon meadow with a sun, two clouds, a bird, a dog and a cottage door"
preserveAspectRatio="xMidYMid slice"
>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#bdeafc" />
<stop offset="1" stop-color="#e8f8ff" />
</linearGradient>
<linearGradient id="grass" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#9be08a" />
<stop offset="1" stop-color="#6fc96b" />
</linearGradient>
<radialGradient id="sunGlow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#ffe680" />
<stop offset="1" stop-color="#ffd23f" />
</radialGradient>
<linearGradient id="door" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ff9d6b" />
<stop offset="1" stop-color="#ff7a45" />
</linearGradient>
</defs>
<!-- backdrop -->
<rect x="0" y="0" width="800" height="520" fill="url(#sky)" />
<path d="M0 360 Q200 320 400 352 T800 348 V520 H0 Z" fill="url(#grass)" />
<path
d="M0 380 Q260 350 540 372 T800 366 V520 H0 Z"
fill="#5cba5b"
opacity="0.55"
/>
<!-- distant hills -->
<ellipse cx="150" cy="372" rx="180" ry="60" fill="#84d57e" opacity="0.6" />
<ellipse cx="650" cy="378" rx="220" ry="70" fill="#84d57e" opacity="0.5" />
<!-- flowers -->
<g class="flowers" aria-hidden="true">
<g transform="translate(120 446)">
<rect x="-2" y="0" width="4" height="34" fill="#3f9d4a" rx="2" />
<circle cx="0" cy="-6" r="9" fill="#ff6f9c" />
<circle cx="0" cy="-6" r="3.4" fill="#ffd23f" />
</g>
<g transform="translate(690 458)">
<rect x="-2" y="0" width="4" height="30" fill="#3f9d4a" rx="2" />
<circle cx="0" cy="-6" r="8" fill="#b78bff" />
<circle cx="0" cy="-6" r="3" fill="#ffd23f" />
</g>
<g transform="translate(430 470)">
<rect x="-2" y="0" width="4" height="26" fill="#3f9d4a" rx="2" />
<circle cx="0" cy="-5" r="7" fill="#ff8a3d" />
<circle cx="0" cy="-5" r="2.6" fill="#fff8ef" />
</g>
</g>
<!-- ===== HOTSPOT: SUN ===== -->
<g
class="hotspot"
id="hs-sun"
data-name="sun"
data-word="Shine!"
tabindex="0"
role="button"
aria-label="Tap the sun"
>
<circle class="hit" cx="680" cy="96" r="78" fill="transparent" />
<g class="hs-art sun-art">
<g class="sun-rays" id="sunRays"></g>
<circle cx="680" cy="96" r="46" fill="url(#sunGlow)" stroke="#ffb800" stroke-width="3" />
<circle cx="666" cy="90" r="5.5" fill="#2c2350" />
<circle cx="694" cy="90" r="5.5" fill="#2c2350" />
<path d="M666 108 Q680 122 694 108" fill="none" stroke="#2c2350" stroke-width="4" stroke-linecap="round" />
<circle cx="660" cy="104" r="6" fill="#ff8a3d" opacity="0.5" />
<circle cx="700" cy="104" r="6" fill="#ff8a3d" opacity="0.5" />
</g>
</g>
<!-- ===== HOTSPOT: CLOUD ===== -->
<g
class="hotspot"
id="hs-cloud"
data-name="cloud"
data-word="Whoosh!"
tabindex="0"
role="button"
aria-label="Tap the cloud"
>
<rect class="hit" x="120" y="60" width="220" height="100" rx="50" fill="transparent" />
<g class="hs-art cloud-art">
<ellipse cx="190" cy="120" rx="50" ry="34" fill="#ffffff" />
<ellipse cx="240" cy="108" rx="44" ry="38" fill="#ffffff" />
<ellipse cx="280" cy="122" rx="40" ry="30" fill="#ffffff" />
<rect x="150" y="118" width="150" height="32" rx="16" fill="#ffffff" />
<circle cx="222" cy="112" r="4" fill="#2c2350" />
<circle cx="256" cy="112" r="4" fill="#2c2350" />
<path d="M226 126 Q240 134 254 126" fill="none" stroke="#2c2350" stroke-width="3" stroke-linecap="round" />
</g>
</g>
<!-- ===== HOTSPOT: BIRD ===== -->
<g
class="hotspot"
id="hs-bird"
data-name="bird"
data-word="Tweet!"
tabindex="0"
role="button"
aria-label="Tap the bird"
>
<circle class="hit" cx="500" cy="190" r="56" fill="transparent" />
<g class="hs-art bird-art">
<ellipse cx="500" cy="196" rx="36" ry="28" fill="#5ec5d6" />
<circle cx="524" cy="178" r="20" fill="#5ec5d6" />
<circle cx="530" cy="174" r="4.4" fill="#2c2350" />
<path d="M542 180 l16 -4 -12 12 z" fill="#ff8a3d" />
<path class="wing" d="M492 192 q-30 -22 -52 -6 q22 18 52 12 z" fill="#46aebd" />
<path d="M470 214 q-22 14 -34 6" fill="none" stroke="#46aebd" stroke-width="6" stroke-linecap="round" />
</g>
</g>
<!-- ===== HOTSPOT: DOG ===== -->
<g
class="hotspot"
id="hs-dog"
data-name="dog"
data-word="Woof!"
tabindex="0"
role="button"
aria-label="Tap the dog"
>
<rect class="hit" x="120" y="318" width="180" height="150" rx="40" fill="transparent" />
<g class="hs-art dog-art">
<ellipse cx="208" cy="452" rx="48" ry="12" fill="#2c2350" opacity="0.16" />
<rect x="166" y="380" width="92" height="62" rx="26" fill="#caa06a" />
<rect x="176" y="424" width="14" height="26" rx="7" fill="#b88a52" />
<rect x="234" y="424" width="14" height="26" rx="7" fill="#b88a52" />
<path class="tail" d="M256 392 q34 -8 30 -34" fill="none" stroke="#caa06a" stroke-width="14" stroke-linecap="round" />
<g class="dog-head">
<circle cx="156" cy="386" r="36" fill="#d8b27c" />
<path d="M126 360 q-14 -24 6 -28 q10 20 6 34 z" fill="#b88a52" />
<path d="M186 360 q14 -24 -6 -28 q-10 20 -6 34 z" fill="#b88a52" />
<circle cx="146" cy="382" r="4.6" fill="#2c2350" />
<circle cx="168" cy="382" r="4.6" fill="#2c2350" />
<ellipse cx="157" cy="396" rx="7" ry="5.4" fill="#2c2350" />
<path d="M150 406 q7 6 14 0" fill="none" stroke="#2c2350" stroke-width="3" stroke-linecap="round" />
</g>
</g>
</g>
<!-- ===== HOTSPOT: DOOR ===== -->
<g
class="hotspot"
id="hs-door"
data-name="door"
data-word="Knock!"
tabindex="0"
role="button"
aria-label="Tap the cottage door"
>
<g class="cottage" aria-hidden="true">
<rect x="560" y="316" width="170" height="118" rx="14" fill="#fff1da" stroke="#e3c79c" stroke-width="4" />
<path d="M548 320 L645 256 L742 320 Z" fill="#ff8a3d" stroke="#e0682a" stroke-width="4" stroke-linejoin="round" />
<rect x="592" y="346" width="34" height="34" rx="8" fill="#bdeafc" stroke="#7fb6cc" stroke-width="3" />
<line x1="609" y1="346" x2="609" y2="380" stroke="#7fb6cc" stroke-width="3" />
<line x1="592" y1="363" x2="626" y2="363" stroke="#7fb6cc" stroke-width="3" />
</g>
<rect class="hit" x="654" y="356" width="58" height="80" rx="10" fill="transparent" />
<g class="hs-art door-art">
<rect x="654" y="356" width="58" height="80" rx="10" fill="url(#door)" stroke="#e0682a" stroke-width="4" />
<circle cx="700" cy="398" r="5" fill="#ffd23f" stroke="#caa20a" stroke-width="1.6" />
<rect x="662" y="364" width="42" height="30" rx="6" fill="#ffb98c" opacity="0.5" />
</g>
</g>
</svg>
<!-- speech bubble layer (JS-injected words go here) -->
<div class="fx-layer" id="fxLayer" aria-hidden="true"></div>
</div>
<p class="hint" id="hint">
Use the mouse, a tap, or press <kbd>Tab</kbd> then <kbd>Enter</kbd> to wake each friend.
</p>
</section>
<aside class="panel" aria-label="Game progress">
<div class="counter-card">
<p class="counter-kicker">Surprises found</p>
<p class="counter-num"><span id="countNow">0</span> <span class="counter-of">/ 5</span></p>
<div class="pips" id="pips" role="list" aria-label="Surprise progress">
<span class="pip" data-for="sun" role="listitem" title="Sun">☀️</span>
<span class="pip" data-for="cloud" role="listitem" title="Cloud">☁️</span>
<span class="pip" data-for="bird" role="listitem" title="Bird">🐦</span>
<span class="pip" data-for="dog" role="listitem" title="Dog">🐶</span>
<span class="pip" data-for="door" role="listitem" title="Door">🚪</span>
</div>
</div>
<div class="found-list-card">
<p class="panel-title">Story log</p>
<ul class="found-list" id="foundList" aria-live="polite">
<li class="found-empty">Tap a friend to start the story…</li>
</ul>
</div>
<button class="btn-reset" id="resetBtn" type="button">
<span aria-hidden="true">↺</span> Start over
</button>
</aside>
</main>
<div class="cheer" id="cheer" role="status" aria-live="polite" hidden>
<div class="cheer-card">
<span class="cheer-emoji" aria-hidden="true">🎉</span>
<h2>You found them all!</h2>
<p>Five happy surprises in Sunny Meadow. Want to play again?</p>
<button class="btn-again" id="againBtn" type="button">Play again</button>
</div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Tap-to-animate Scene
A single hand-drawn picture-book page rendered entirely as inline SVG: a sunny meadow with rolling hills, wildflowers, a beaming sun, two clouds, a chirpy bird, a sleepy puppy, and a little cottage. Five of these characters are tappable hotspots, and the goal printed across the top is simple and inviting — find 5 surprises. The whole thing reads as a friendly storybook spread, with soft bright colors, thick playful outlines, and rounded everything.
Tapping (or clicking, or pressing Enter on a focused hotspot) wakes a character with its own personality. The sun spins its rays and pops, the cloud drifts sideways, the bird hops and flaps, the puppy nods and wags its tail, and the cottage door swings open. Each tap fires a hand-lettered sound-word bubble — “Shine!”, “Whoosh!”, “Tweet!”, “Woof!”, “Knock!” — a burst of colored sparkle particles at the touch point, and a short friendly chime via WebAudio (no audio files). A live counter, five progress pips, and a running story log track which surprises have been discovered, and finding all five triggers a bouncing confetti celebration overlay.
Everything is built for small hands and assistive tech: every hotspot is a real keyboard-focusable button with a focus-visible ring, the toolbar offers an easy-read (dyslexia-friendly) font toggle and a sound-words on/off switch, touch targets stay large, the layout collapses to a single column on phones, and all motion is suppressed under prefers-reduced-motion while the interactions still work.
Illustrative kids’ UI only — fictional stories, characters, and audio.