Storybook — Animated Mascot Guide
A friendly storybook mascot named Pip, a glowing inline-SVG fox-sprite that lives in a rounded scene and idles forever with blinking eyes and a gentle bob. Tapping Pip makes it wave, jump, and giggle with a speech bubble of helpful tips, while a mood selector swaps between happy, curious, and sleepy expressions, each with its own animation and palette. A Guide me button runs a tiny tooltip tour around the controls, every motion respects reduced-motion, and an easy-read toggle loosens the type.
MCP
Code
/* ============================================================
Storybook — Animated Mascot Guide
Palette + tokens first
============================================================ */
:root {
--bg: #fff8ef;
--bg-2: #fff1e0;
--ink: #2c2350;
--ink-soft: #5b527e;
--card: #ffffff;
--primary: #ff8a3d;
--primary-ink: #c5571a;
--secondary: #5ec5d6;
--accent: #ffd23f;
--pink: #ff6f9c;
--green: #7bd389;
--line: #f0dec7;
--r: 22px;
--r-lg: 30px;
--r-pill: 999px;
--shadow-sm: 0 6px 16px rgba(44, 35, 80, 0.08);
--shadow-md: 0 14px 34px rgba(44, 35, 80, 0.14);
--shadow-pop: 0 10px 0 rgba(44, 35, 80, 0.12);
--font-display: "Baloo 2", system-ui, sans-serif;
--font-body: "Nunito", system-ui, sans-serif;
--space: 18px;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100dvh;
font-family: var(--font-body);
font-size: 17px;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(120% 80% at 12% 0%, #fff5e4 0%, transparent 55%),
radial-gradient(110% 90% at 92% 8%, #eafaf6 0%, transparent 50%),
var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Easy-read mode */
body.easy-read {
--font-body: "Comic Sans MS", "Nunito", system-ui, sans-serif;
letter-spacing: 0.035em;
word-spacing: 0.12em;
line-height: 1.8;
}
h1, h2, p { margin: 0; }
button { font-family: inherit; }
.visually-hidden {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0);
white-space: nowrap; border: 0;
}
.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.18s ease;
text-decoration: none;
font-weight: 700;
}
.skip-link:focus { top: 12px; }
:focus-visible {
outline: 3px solid var(--secondary);
outline-offset: 3px;
border-radius: 8px;
}
/* ============================================================
Top bar
============================================================ */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 16px clamp(16px, 4vw, 40px);
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(6px);
border-bottom: 3px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-spark {
display: grid;
place-items: center;
width: 48px; height: 48px;
background: #fff;
border: 3px solid var(--ink);
border-radius: 16px;
box-shadow: var(--shadow-sm);
animation: spark-wiggle 4.5s ease-in-out infinite;
}
.brand-text { display: flex; flex-direction: column; line-height: 1.2; }
.brand-text strong { font-family: var(--font-display); font-size: 1.2rem; font-weight: 800; }
.brand-text span { font-size: 0.84rem; color: var(--ink-soft); }
.chip-toggle {
display: inline-flex;
align-items: center;
gap: 9px;
min-height: 48px;
padding: 0 18px;
border: 3px solid var(--ink);
border-radius: var(--r-pill);
background: #fff;
color: var(--ink);
font-weight: 800;
font-size: 0.95rem;
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: transform 0.12s ease, background 0.2s ease;
}
.chip-toggle:hover { transform: translateY(-2px); }
.chip-toggle:active { transform: translateY(0); }
.chip-dot {
width: 18px; height: 18px;
border-radius: 50%;
background: var(--line);
border: 2px solid var(--ink);
transition: background 0.2s ease;
}
.chip-toggle[aria-checked="true"] { background: var(--green); }
.chip-toggle[aria-checked="true"] .chip-dot { background: #fff; }
/* ============================================================
Layout
============================================================ */
.layout {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
gap: clamp(18px, 3vw, 32px);
max-width: 1080px;
margin: 0 auto;
padding: clamp(20px, 4vw, 40px);
align-items: start;
}
/* ============================================================
Mascot stage
============================================================ */
.stage-wrap { text-align: center; }
.stage-title {
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.8rem, 5vw, 2.6rem);
line-height: 1.1;
}
.stage-sub { color: var(--ink-soft); margin-top: 6px; }
.stage {
position: relative;
margin: 18px auto 8px;
max-width: 420px;
aspect-ratio: 1 / 1;
display: grid;
place-items: center;
background:
radial-gradient(80% 60% at 50% 88%, #ffe9cf 0%, transparent 70%),
linear-gradient(180deg, #fffdf8 0%, #fff1de 100%);
border: 4px solid var(--ink);
border-radius: var(--r-lg);
box-shadow: var(--shadow-md);
overflow: hidden;
}
/* decorative floor */
.stage::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: 0;
height: 26%;
background: linear-gradient(180deg, transparent, rgba(126, 211, 137, 0.22));
}
.stage-glow {
position: absolute;
width: 62%; height: 62%;
border-radius: 50%;
background: radial-gradient(circle, var(--accent) 0%, transparent 68%);
opacity: 0.5;
filter: blur(4px);
animation: glow-pulse 4s ease-in-out infinite;
}
/* ---- mood theming via data-mood ---- */
.stage[data-mood="happy"] { --glow: var(--accent); }
.stage[data-mood="curious"] { --glow: var(--secondary); }
.stage[data-mood="sleepy"] { --glow: #b9a9ff; }
.stage[data-mood="curious"] .stage-glow { background: radial-gradient(circle, var(--secondary) 0%, transparent 68%); }
.stage[data-mood="sleepy"] .stage-glow { background: radial-gradient(circle, #c7b9ff 0%, transparent 68%); opacity: 0.4; }
/* ============================================================
The mascot button + SVG
============================================================ */
.pip {
position: relative;
z-index: 2;
border: 0;
background: transparent;
padding: 6px;
cursor: pointer;
border-radius: 50%;
-webkit-tap-highlight-color: transparent;
}
.pip-svg { width: min(78%, 320px); height: auto; display: block; }
.stage .pip-svg { width: clamp(180px, 60%, 280px); }
/* idle bob applied to body+head group via transform on whole svg */
.pip-svg { animation: pip-bob 3.2s ease-in-out infinite; transform-origin: 50% 90%; }
.pip:hover .pip-svg { animation-duration: 2.2s; }
.pip:active .pip-svg { transform: scale(0.96); }
/* tail sway */
.pip-tail { transform-origin: 150px 150px; animation: tail-sway 2.6s ease-in-out infinite; }
/* blinking — driven by a class toggled in JS for precise timing,
plus a CSS fallback animation */
.eye .lid { opacity: 0; transition: opacity 0.08s ease; }
.pip.blink .eye .lid { opacity: 1; }
.pip.blink .eye .eye-white,
.pip.blink .eye .pupil,
.pip.blink .eye .glint { opacity: 0; }
/* brows hidden by default, shown for curious */
.brow { opacity: 0; transition: opacity 0.25s ease; }
/* zzz hidden unless sleepy */
.pip-zzz { opacity: 0; transition: opacity 0.3s ease; }
.pip-zzz text { animation: zzz-float 2.6s ease-in-out infinite; }
.pip-zzz text:nth-child(2) { animation-delay: 0.4s; }
.pip-zzz text:nth-child(3) { animation-delay: 0.8s; }
/* waving arm: idle tucked, waves on react */
.pip-arm { transform-origin: 168px 150px; transform: rotate(8deg); }
/* ---- mood: HAPPY (default expression already drawn) ---- */
.stage[data-mood="happy"] .mouth { d: path("M96 118 q14 14 28 0"); }
/* ---- mood: CURIOUS ---- */
.stage[data-mood="curious"] .pip-head { transform: rotate(-7deg); transform-origin: 110px 120px; transition: transform 0.4s ease; }
.stage[data-mood="curious"] .brow-r { opacity: 1; transform: translateY(-4px); }
.stage[data-mood="curious"] .brow-l { opacity: 1; }
.stage[data-mood="curious"] .mouth { d: path("M98 122 q12 6 26 -2"); }
.stage[data-mood="curious"] .pip-svg { animation: pip-bob 3.6s ease-in-out infinite; }
/* ---- mood: SLEEPY ---- */
.stage[data-mood="sleepy"] .eye .lid { opacity: 1; }
.stage[data-mood="sleepy"] .eye .eye-white,
.stage[data-mood="sleepy"] .eye .pupil,
.stage[data-mood="sleepy"] .eye .glint { opacity: 0.18; }
.stage[data-mood="sleepy"] .mouth { d: path("M100 120 q10 8 20 0"); }
.stage[data-mood="sleepy"] .cheek { opacity: 0.3; }
.stage[data-mood="sleepy"] .pip-zzz { opacity: 1; }
.stage[data-mood="sleepy"] .pip-svg { animation: pip-bob 5s ease-in-out infinite; }
.stage[data-mood="sleepy"] .pip-tail { animation-duration: 4.2s; }
/* ---- reaction classes (added briefly via JS) ---- */
.pip.reacting .pip-svg { animation: pip-jump 0.65s ease; }
.pip.reacting .pip-arm { animation: arm-wave 0.65s ease; }
.pip.reacting .cheek { opacity: 0.85; }
.pip.giggle .pip-head { animation: head-giggle 0.55s ease; }
/* ============================================================
Speech bubble
============================================================ */
.bubble {
position: absolute;
z-index: 4;
top: 8%;
left: 50%;
transform: translateX(-50%) translateY(-8px) scale(0.9);
max-width: 78%;
background: #fff;
color: var(--ink);
border: 3px solid var(--ink);
border-radius: 20px;
padding: 12px 16px;
box-shadow: var(--shadow-pop);
opacity: 0;
transition: opacity 0.22s ease, transform 0.28s cubic-bezier(.2,1.5,.4,1);
pointer-events: none;
}
.bubble.show {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
.bubble-text { font-weight: 700; font-size: 0.98rem; }
.bubble-tail {
position: absolute;
bottom: -14px; left: 50%;
transform: translateX(-50%);
width: 22px; height: 16px;
background: #fff;
border-right: 3px solid var(--ink);
border-bottom: 3px solid var(--ink);
border-bottom-right-radius: 6px;
clip-path: polygon(0 0, 100% 0, 100% 100%);
rotate: 45deg;
}
.mood-caption {
margin-top: 8px;
color: var(--ink-soft);
font-weight: 700;
font-size: 0.95rem;
}
/* ============================================================
Control panel
============================================================ */
.panel {
background: var(--card);
border: 4px solid var(--ink);
border-radius: var(--r-lg);
box-shadow: var(--shadow-md);
padding: clamp(18px, 3vw, 26px);
display: grid;
gap: 18px;
position: sticky;
top: 18px;
}
.control-group { border: 0; margin: 0; padding: 0; display: grid; gap: 12px; }
.control-group legend {
font-family: var(--font-display);
font-weight: 800;
font-size: 1.1rem;
padding: 0;
margin-bottom: 4px;
}
.mood-buttons { display: grid; gap: 10px; }
.mood-btn {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
padding: 0 18px;
border: 3px solid var(--ink);
border-radius: var(--r);
background: #fff;
color: var(--ink);
font-weight: 800;
font-size: 1.02rem;
cursor: pointer;
text-align: left;
box-shadow: 0 5px 0 rgba(44, 35, 80, 0.12);
transition: transform 0.12s ease, background 0.2s ease, box-shadow 0.12s ease;
}
.mood-btn .mood-emoji { font-size: 1.4rem; line-height: 1; }
.mood-btn:hover { transform: translateY(-2px); }
.mood-btn:active { transform: translateY(2px); box-shadow: 0 1px 0 rgba(44,35,80,0.12); }
.mood-btn.is-active { background: var(--accent); }
.mood-btn[data-mood="curious"].is-active { background: var(--secondary); color: #08323a; }
.mood-btn[data-mood="sleepy"].is-active { background: #c7b9ff; }
.primary-btn,
.ghost-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 54px;
padding: 0 20px;
border-radius: var(--r-pill);
font-weight: 800;
font-size: 1.02rem;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.2s ease;
}
.primary-btn {
border: 3px solid var(--ink);
background: var(--primary);
color: #fff;
box-shadow: 0 6px 0 var(--primary-ink);
}
.primary-btn:hover { transform: translateY(-2px); }
.primary-btn:active { transform: translateY(4px); box-shadow: 0 2px 0 var(--primary-ink); }
.ghost-btn {
border: 3px solid var(--ink);
background: #fff;
color: var(--ink);
box-shadow: 0 5px 0 rgba(44,35,80,0.12);
}
.ghost-btn:hover { transform: translateY(-2px); }
.ghost-btn:active { transform: translateY(3px); box-shadow: 0 1px 0 rgba(44,35,80,0.12); }
.primary-btn.small, .ghost-btn.small { min-height: 46px; font-size: 0.95rem; padding: 0 16px; }
.stat-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
background: linear-gradient(180deg, #fff, #fff5e7);
border: 3px dashed var(--primary);
border-radius: var(--r);
}
.stat-label { font-weight: 700; color: var(--ink-soft); }
.stat-value {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 800;
color: var(--primary-ink);
}
.stat-value.pop { animation: count-pop 0.4s ease; }
.panel-foot {
font-size: 0.85rem;
color: var(--ink-soft);
text-align: center;
border-top: 2px dashed var(--line);
padding-top: 14px;
}
/* ============================================================
Guided tour
============================================================ */
.tour { position: fixed; inset: 0; z-index: 60; }
.tour[hidden] { display: none; }
.tour::before {
content: "";
position: absolute;
inset: 0;
background: rgba(44, 35, 80, 0.55);
animation: fade-in 0.25s ease;
}
.tour-spot {
position: absolute;
border-radius: 24px;
border: 4px solid var(--accent);
box-shadow: 0 0 0 9999px rgba(44, 35, 80, 0.55);
transition: top 0.35s ease, left 0.35s ease, width 0.35s ease, height 0.35s ease;
pointer-events: none;
}
.tour-card {
position: absolute;
z-index: 2;
width: min(340px, calc(100vw - 32px));
background: #fff;
border: 4px solid var(--ink);
border-radius: var(--r-lg);
box-shadow: var(--shadow-md);
padding: 22px;
transition: top 0.35s ease, left 0.35s ease;
animation: pop-in 0.3s cubic-bezier(.2,1.4,.4,1);
}
.tour-step {
display: inline-block;
font-weight: 800;
font-size: 0.78rem;
color: var(--primary-ink);
background: #fff1e0;
border-radius: var(--r-pill);
padding: 4px 12px;
margin-bottom: 8px;
}
.tour-title { font-family: var(--font-display); font-weight: 800; font-size: 1.4rem; }
.tour-body { margin-top: 6px; color: var(--ink-soft); font-weight: 600; }
.tour-actions { display: flex; justify-content: space-between; gap: 10px; margin-top: 18px; }
/* ============================================================
Toast
============================================================ */
.toast {
position: fixed;
left: 50%; bottom: 22px;
transform: translateX(-50%) translateY(14px);
background: var(--ink);
color: #fff;
font-weight: 700;
padding: 12px 20px;
border-radius: var(--r-pill);
box-shadow: var(--shadow-md);
z-index: 80;
opacity: 0;
transition: opacity 0.2s ease, transform 0.25s ease;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast[hidden] { display: none; }
/* ============================================================
Keyframes
============================================================ */
@keyframes pip-bob {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-8px) rotate(-1deg); }
}
@keyframes pip-jump {
0% { transform: translateY(0) scale(1); }
30% { transform: translateY(-26px) scale(1.05, 0.96); }
55% { transform: translateY(0) scale(0.96, 1.06); }
75% { transform: translateY(-8px) scale(1.02, 0.99); }
100% { transform: translateY(0) scale(1); }
}
@keyframes arm-wave {
0%, 100% { transform: rotate(8deg); }
25% { transform: rotate(-26deg); }
50% { transform: rotate(10deg); }
75% { transform: rotate(-20deg); }
}
@keyframes head-giggle {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(5deg); }
50% { transform: rotate(-5deg); }
75% { transform: rotate(3deg); }
}
@keyframes tail-sway {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(-12deg); }
}
@keyframes glow-pulse {
0%, 100% { transform: scale(1); opacity: 0.45; }
50% { transform: scale(1.12); opacity: 0.62; }
}
@keyframes zzz-float {
0% { transform: translate(0, 0); opacity: 0; }
20% { opacity: 1; }
100% { transform: translate(8px, -16px); opacity: 0; }
}
@keyframes spark-wiggle {
0%, 92%, 100% { transform: rotate(0deg); }
94% { transform: rotate(-12deg); }
96% { transform: rotate(12deg); }
98% { transform: rotate(-6deg); }
}
@keyframes count-pop {
0% { transform: scale(1); }
40% { transform: scale(1.35); color: var(--green); }
100% { transform: scale(1); }
}
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes pop-in {
from { opacity: 0; transform: scale(0.88); }
to { opacity: 1; transform: scale(1); }
}
/* ============================================================
Responsive
============================================================ */
@media (max-width: 820px) {
.layout { grid-template-columns: 1fr; }
.panel { position: static; }
}
@media (max-width: 420px) {
body { font-size: 16px; }
.brand-text span { display: none; }
.stage { max-width: 100%; }
.control-group legend { font-size: 1rem; }
}
/* ============================================================
Reduced motion — calm everything down
============================================================ */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.pip-svg, .pip-tail, .stage-glow, .pip-zzz text, .brand-spark { animation: none !important; }
.pip.reacting .pip-svg,
.pip.reacting .pip-arm,
.pip.giggle .pip-head { animation: none !important; }
.bubble { transition: opacity 0.2s ease !important; transform: translateX(-50%) !important; }
.bubble.show { transform: translateX(-50%) !important; }
.pip-zzz { opacity: 1; }
}/* ============================================================
Storybook — Animated Mascot Guide (Pip)
Vanilla JS, no libraries.
============================================================ */
(function () {
"use strict";
var prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
/* ---- element refs ---- */
var stage = document.getElementById("mascot-stage");
var pip = document.getElementById("pip");
var bubble = document.getElementById("bubble");
var bubbleText = document.getElementById("bubbleText");
var moodCaption = document.getElementById("moodCaption");
var hugCountEl = document.getElementById("hugCount");
var easyToggle = document.getElementById("easyReadToggle");
var guideBtn = document.getElementById("guideBtn");
var tipBtn = document.getElementById("tipBtn");
var moodButtons = Array.prototype.slice.call(
document.querySelectorAll(".mood-btn")
);
var toastEl = document.getElementById("toast");
/* ============================================================
Toast helper
============================================================ */
var toastTimer;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.hidden = false;
/* force reflow so transition runs */
void toastEl.offsetWidth;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
setTimeout(function () {
toastEl.hidden = true;
}, 250);
}, 2200);
}
/* ============================================================
Mood data
============================================================ */
var MOODS = {
happy: {
label: "Happy",
caption: "Mood: Happy — Pip is bouncing with joy.",
tips: [
"Tap me anytime and I'll cheer you on!",
"Reading just one page a day grows a big brain. 📖",
"You're doing amazing. Keep turning those pages!"
]
},
curious: {
label: "Curious",
caption: "Mood: Curious — Pip is wondering what happens next.",
tips: [
"Ooh, what do YOU think happens next in the story?",
"Curious readers ask lots of questions. Ask away!",
"Try guessing the ending before you reach it. 🔎"
]
},
sleepy: {
label: "Sleepy",
caption: "Mood: Sleepy — Pip is ready for a bedtime tale.",
tips: [
"Bedtime stories are the coziest stories. 🌙",
"A quiet voice makes sleepy tales feel magical.",
"One more page... then sweet dreams. 💤"
]
}
};
/* ============================================================
Idle blinking (random, polite timing)
============================================================ */
var blinkTimer;
function scheduleBlink() {
var delay = 2200 + Math.random() * 3200;
blinkTimer = setTimeout(function () {
/* don't blink while sleepy (eyes already closed) */
if (stage.getAttribute("data-mood") !== "sleepy" && !prefersReduced) {
pip.classList.add("blink");
setTimeout(function () {
pip.classList.remove("blink");
}, 150);
}
scheduleBlink();
}, delay);
}
if (!prefersReduced) scheduleBlink();
/* ============================================================
Speech bubble
============================================================ */
var bubbleTimer;
function showBubble(text, hold) {
bubbleText.textContent = text;
bubble.hidden = false;
void bubble.offsetWidth;
bubble.classList.add("show");
clearTimeout(bubbleTimer);
bubbleTimer = setTimeout(function () {
hideBubble();
}, hold || 3600);
}
function hideBubble() {
bubble.classList.remove("show");
setTimeout(function () {
if (!bubble.classList.contains("show")) bubble.hidden = true;
}, 260);
}
/* rotating tip index per mood so tips don't repeat back-to-back */
var tipIndex = { happy: 0, curious: 0, sleepy: 0 };
function nextTip(mood) {
var list = MOODS[mood].tips;
var i = tipIndex[mood] % list.length;
tipIndex[mood]++;
return list[i];
}
/* ============================================================
React to clicks (wave + jump + giggle)
============================================================ */
var hugs = 0;
function reactToPip() {
var mood = stage.getAttribute("data-mood");
/* animation classes (skipped visually under reduced-motion via CSS) */
pip.classList.add("reacting", "giggle");
setTimeout(function () {
pip.classList.remove("reacting", "giggle");
}, 700);
/* show a tip bubble */
showBubble(nextTip(mood));
/* count the hug */
hugs++;
hugCountEl.textContent = String(hugs);
hugCountEl.classList.remove("pop");
void hugCountEl.offsetWidth;
hugCountEl.classList.add("pop");
}
pip.addEventListener("click", reactToPip);
/* ============================================================
Mood selection (radiogroup behaviour)
============================================================ */
function setMood(mood, announce) {
if (!MOODS[mood]) return;
stage.setAttribute("data-mood", mood);
moodButtons.forEach(function (btn) {
var on = btn.getAttribute("data-mood") === mood;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-checked", on ? "true" : "false");
btn.tabIndex = on ? 0 : -1;
});
moodCaption.textContent = MOODS[mood].caption;
if (announce) {
showBubble(MOODS[mood].tips[0], 3000);
}
}
moodButtons.forEach(function (btn, idx) {
btn.addEventListener("click", function () {
setMood(btn.getAttribute("data-mood"), true);
btn.focus();
});
/* arrow-key roving within the radiogroup */
btn.addEventListener("keydown", function (e) {
var dir = 0;
if (e.key === "ArrowRight" || e.key === "ArrowDown") dir = 1;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") dir = -1;
else return;
e.preventDefault();
var next =
(idx + dir + moodButtons.length) % moodButtons.length;
var target = moodButtons[next];
setMood(target.getAttribute("data-mood"), true);
target.focus();
});
});
/* init roving tabindex */
setMood("happy", false);
/* manual "new tip" button */
tipBtn.addEventListener("click", function () {
var mood = stage.getAttribute("data-mood");
showBubble(nextTip(mood));
pip.classList.add("giggle");
setTimeout(function () {
pip.classList.remove("giggle");
}, 600);
});
/* ============================================================
Easy-read toggle
============================================================ */
easyToggle.addEventListener("click", function () {
var on = easyToggle.getAttribute("aria-checked") === "true";
on = !on;
easyToggle.setAttribute("aria-checked", on ? "true" : "false");
document.body.classList.toggle("easy-read", on);
toast(on ? "Easy-read font on" : "Easy-read font off");
});
/* ============================================================
Guided tour
============================================================ */
var tour = document.getElementById("tour");
var tourSpot = document.getElementById("tourSpot");
var tourCard = document.getElementById("tourCard");
var tourTitle = document.getElementById("tourTitle");
var tourBody = document.getElementById("tourBody");
var tourStepLabel = document.getElementById("tourStepLabel");
var tourNext = document.getElementById("tourNext");
var tourSkip = document.getElementById("tourSkip");
var STEPS = [
{
el: function () {
return document.getElementById("moodGroup");
},
title: "Pick a mood",
body: "Tap Happy, Curious, or Sleepy to change Pip's face and animation."
},
{
el: function () {
return pip;
},
title: "Tap Pip!",
body: "Give Pip a tap. It'll wave, jump, giggle, and share a reading tip."
},
{
el: function () {
return tipBtn;
},
title: "Need a hint?",
body: "Press New tip whenever you'd like Pip to say something helpful."
},
{
el: function () {
return easyToggle;
},
title: "Easy-read mode",
body: "Flip this on for a friendlier font and roomier spacing. You're all set!"
}
];
var stepIdx = 0;
var lastFocused = null;
function placeTour(target) {
var rect = target.getBoundingClientRect();
var pad = 10;
tourSpot.style.top = rect.top - pad + "px";
tourSpot.style.left = rect.left - pad + "px";
tourSpot.style.width = rect.width + pad * 2 + "px";
tourSpot.style.height = rect.height + pad * 2 + "px";
/* position the card below the target, clamped to viewport */
var cardW = Math.min(340, window.innerWidth - 32);
var top = rect.bottom + 16;
var card = tourCard;
/* if it would overflow the bottom, place above */
if (top + 200 > window.innerHeight && rect.top - 16 > 200) {
top = rect.top - 16 - card.offsetHeight;
}
var left = rect.left + rect.width / 2 - cardW / 2;
left = Math.max(16, Math.min(left, window.innerWidth - cardW - 16));
card.style.top = Math.max(16, top) + "px";
card.style.left = left + "px";
}
function renderStep() {
var step = STEPS[stepIdx];
var target = step.el();
if (!target) return;
target.scrollIntoView({
behavior: prefersReduced ? "auto" : "smooth",
block: "center"
});
tourTitle.textContent = step.title;
tourBody.textContent = step.body;
tourStepLabel.textContent = "Step " + (stepIdx + 1) + " of " + STEPS.length;
tourNext.textContent =
stepIdx === STEPS.length - 1 ? "Done ✓" : "Next →";
/* wait a tick for any scroll/layout then place */
requestAnimationFrame(function () {
requestAnimationFrame(function () {
placeTour(target);
});
});
}
function openTour() {
lastFocused = document.activeElement;
stepIdx = 0;
tour.hidden = false;
renderStep();
tourNext.focus();
document.addEventListener("keydown", onTourKey);
window.addEventListener("resize", onTourResize);
}
function closeTour(done) {
tour.hidden = true;
document.removeEventListener("keydown", onTourKey);
window.removeEventListener("resize", onTourResize);
if (lastFocused && typeof lastFocused.focus === "function") {
lastFocused.focus();
}
if (done) {
toast("Tour complete — have fun reading! 🦊");
showBubble("That's the whole tour. Let's read together!", 3200);
}
}
function advance() {
if (stepIdx >= STEPS.length - 1) {
closeTour(true);
return;
}
stepIdx++;
renderStep();
}
function onTourResize() {
var step = STEPS[stepIdx];
var target = step && step.el();
if (target) placeTour(target);
}
function onTourKey(e) {
if (e.key === "Escape") {
closeTour(false);
} else if (e.key === "ArrowRight" || e.key === "Enter") {
/* let Enter on buttons work normally; only handle ArrowRight here */
if (e.key === "ArrowRight") {
e.preventDefault();
advance();
}
} else if (e.key === "ArrowLeft") {
e.preventDefault();
if (stepIdx > 0) {
stepIdx--;
renderStep();
}
} else if (e.key === "Tab") {
/* simple focus trap inside the card */
var focusables = tourCard.querySelectorAll("button");
if (!focusables.length) return;
var first = focusables[0];
var last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
guideBtn.addEventListener("click", openTour);
tourNext.addEventListener("click", advance);
tourSkip.addEventListener("click", function () {
closeTour(false);
});
/* ============================================================
Friendly first-load greeting
============================================================ */
setTimeout(function () {
showBubble("Hi! I'm Pip. Tap me for a tip! 👋", 4200);
}, 900);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Storybook — Animated Mascot Guide</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:wght@400;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#mascot-stage">Skip to mascot</a>
<header class="topbar" role="banner">
<div class="brand">
<span class="brand-spark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none">
<path d="M12 2l2.4 6.2L21 9.4l-5 4.2L17.6 21 12 17.3 6.4 21 8 13.6l-5-4.2 6.6-1.2L12 2z"
fill="#ffd23f" stroke="#2c2350" stroke-width="1.4" stroke-linejoin="round" />
</svg>
</span>
<div class="brand-text">
<strong>Pip's Storybook</strong>
<span>Your friendly reading buddy</span>
</div>
</div>
<div class="topbar-toggles">
<button id="easyReadToggle" class="chip-toggle" type="button" role="switch" aria-checked="false">
<span class="chip-dot" aria-hidden="true"></span>
Easy-read
</button>
</div>
</header>
<main class="layout" id="main">
<!-- Mascot scene -->
<section class="stage-wrap" aria-labelledby="stageHeading">
<h1 id="stageHeading" class="stage-title">Say hi to Pip!</h1>
<p class="stage-sub">Tap Pip for a tip, pick a mood, or take the tour.</p>
<div id="mascot-stage" class="stage" data-mood="happy">
<div class="stage-glow" aria-hidden="true"></div>
<!-- Speech bubble -->
<div id="bubble" class="bubble" role="status" aria-live="polite" hidden>
<p id="bubbleText" class="bubble-text"></p>
<span class="bubble-tail" aria-hidden="true"></span>
</div>
<!-- The mascot: an interactive button wrapping inline SVG -->
<button
id="pip"
class="pip"
type="button"
aria-label="Pip the fox. Press to make Pip react with a friendly tip."
aria-describedby="pipHint"
>
<svg class="pip-svg" viewBox="0 0 220 240" width="220" height="240" role="img" aria-hidden="true">
<!-- shadow -->
<ellipse class="pip-shadow" cx="110" cy="222" rx="62" ry="12" fill="#2c2350" opacity="0.12" />
<!-- tail -->
<g class="pip-tail">
<path d="M150 168 C198 160 206 120 180 96 C198 134 168 156 146 158 Z"
fill="#ff8a3d" stroke="#2c2350" stroke-width="4" stroke-linejoin="round" />
<path d="M178 100 C192 124 174 146 156 152" fill="none" stroke="#fff8ef" stroke-width="8"
stroke-linecap="round" opacity="0.6" />
</g>
<!-- body -->
<path class="pip-body" d="M62 150 C56 198 86 214 110 214 C134 214 164 198 158 150 Z"
fill="#ffb066" stroke="#2c2350" stroke-width="4" stroke-linejoin="round" />
<path class="pip-belly" d="M84 158 C82 196 100 206 110 206 C120 206 138 196 136 158 Z"
fill="#fff3e2" />
<!-- waving arm -->
<g class="pip-arm">
<path d="M150 158 C172 150 184 132 180 118" fill="none" stroke="#ff8a3d" stroke-width="14"
stroke-linecap="round" />
<circle cx="181" cy="114" r="11" fill="#ffb066" stroke="#2c2350" stroke-width="4" />
</g>
<!-- resting arm -->
<path d="M70 158 C56 168 54 184 60 194" fill="none" stroke="#ff8a3d" stroke-width="14"
stroke-linecap="round" />
<!-- head -->
<g class="pip-head">
<!-- ears -->
<path d="M70 70 L52 26 L96 56 Z" fill="#ff8a3d" stroke="#2c2350" stroke-width="4" stroke-linejoin="round" />
<path d="M150 70 L168 26 L124 56 Z" fill="#ff8a3d" stroke="#2c2350" stroke-width="4" stroke-linejoin="round" />
<path d="M72 60 L62 38 L84 54 Z" fill="#ffd6b0" />
<path d="M148 60 L158 38 L136 54 Z" fill="#ffd6b0" />
<!-- face -->
<path d="M58 88 C58 52 86 40 110 40 C134 40 162 52 162 88 C162 124 138 144 110 144 C82 144 58 124 58 88 Z"
fill="#ffb066" stroke="#2c2350" stroke-width="4" />
<!-- muzzle -->
<path d="M84 104 C84 132 110 140 110 140 C110 140 136 132 136 104 C136 92 124 86 110 86 C96 86 84 92 84 104 Z"
fill="#fff3e2" />
<!-- cheeks -->
<circle class="cheek" cx="80" cy="108" r="9" fill="#ff6f9c" opacity="0.55" />
<circle class="cheek" cx="140" cy="108" r="9" fill="#ff6f9c" opacity="0.55" />
<!-- eyebrow (curious) -->
<path class="brow brow-l" d="M74 70 q12 -8 22 -2" fill="none" stroke="#2c2350" stroke-width="4" stroke-linecap="round" />
<path class="brow brow-r" d="M124 68 q12 -6 22 2" fill="none" stroke="#2c2350" stroke-width="4" stroke-linecap="round" />
<!-- eyes -->
<g class="eye eye-l">
<circle class="eye-white" cx="88" cy="86" r="13" fill="#fff" stroke="#2c2350" stroke-width="3" />
<circle class="pupil" cx="90" cy="88" r="6" fill="#2c2350" />
<circle class="glint" cx="93" cy="84" r="2.4" fill="#fff" />
<path class="lid" d="M75 86 q13 12 26 0" fill="none" stroke="#2c2350" stroke-width="4" stroke-linecap="round" />
</g>
<g class="eye eye-r">
<circle class="eye-white" cx="132" cy="86" r="13" fill="#fff" stroke="#2c2350" stroke-width="3" />
<circle class="pupil" cx="130" cy="88" r="6" fill="#2c2350" />
<circle class="glint" cx="133" cy="84" r="2.4" fill="#fff" />
<path class="lid" d="M119 86 q13 12 26 0" fill="none" stroke="#2c2350" stroke-width="4" stroke-linecap="round" />
</g>
<!-- nose -->
<path d="M104 104 q6 8 12 0 q-6 7 -12 0 Z" fill="#2c2350" />
<!-- mouth -->
<path class="mouth" d="M96 118 q14 14 28 0" fill="none" stroke="#2c2350" stroke-width="4" stroke-linecap="round" />
</g>
<!-- sleepy Zzz -->
<g class="pip-zzz" aria-hidden="true">
<text x="168" y="58" font-family="'Baloo 2', sans-serif" font-size="20" font-weight="800" fill="#5ec5d6">z</text>
<text x="182" y="42" font-family="'Baloo 2', sans-serif" font-size="26" font-weight="800" fill="#5ec5d6">z</text>
<text x="200" y="24" font-family="'Baloo 2', sans-serif" font-size="32" font-weight="800" fill="#5ec5d6">z</text>
</g>
</svg>
</button>
<span id="pipHint" class="visually-hidden">Tap Pip to wave and giggle and reveal a storybook tip.</span>
</div>
<p id="moodCaption" class="mood-caption" aria-live="polite">Mood: Happy — Pip is bouncing with joy.</p>
</section>
<!-- Controls -->
<aside class="panel" aria-label="Mascot controls">
<fieldset class="control-group" id="moodGroup">
<legend>Pick a mood</legend>
<div class="mood-buttons" role="radiogroup" aria-label="Mascot mood">
<button class="mood-btn is-active" type="button" role="radio" aria-checked="true" data-mood="happy">
<span class="mood-emoji" aria-hidden="true">😄</span> Happy
</button>
<button class="mood-btn" type="button" role="radio" aria-checked="false" data-mood="curious">
<span class="mood-emoji" aria-hidden="true">🤔</span> Curious
</button>
<button class="mood-btn" type="button" role="radio" aria-checked="false" data-mood="sleepy">
<span class="mood-emoji" aria-hidden="true">😴</span> Sleepy
</button>
</div>
</fieldset>
<div class="control-group">
<button id="guideBtn" class="primary-btn" type="button">
<span aria-hidden="true">🧭</span> Guide me
</button>
<button id="tipBtn" class="ghost-btn" type="button">
<span aria-hidden="true">💬</span> New tip
</button>
</div>
<div class="stat-card" aria-live="polite">
<span class="stat-label">Hugs given to Pip</span>
<strong id="hugCount" class="stat-value">0</strong>
</div>
<p class="panel-foot">Reusable onboarding helper — drop Pip into any storybook app.</p>
</aside>
</main>
<!-- Guided tour overlay -->
<div id="tour" class="tour" hidden>
<div class="tour-spot" id="tourSpot" aria-hidden="true"></div>
<div class="tour-card" id="tourCard" role="dialog" aria-modal="true" aria-labelledby="tourTitle" aria-describedby="tourBody">
<div class="tour-step" id="tourStepLabel">Step 1 of 4</div>
<h2 id="tourTitle" class="tour-title">Welcome!</h2>
<p id="tourBody" class="tour-body">Pip will show you around in just a few steps.</p>
<div class="tour-actions">
<button id="tourSkip" class="ghost-btn small" type="button">Skip</button>
<button id="tourNext" class="primary-btn small" type="button">Next →</button>
</div>
</div>
</div>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script src="script.js"></script>
</body>
</html>Animated Mascot Guide
Meet Pip, a friendly little fox-sprite drawn entirely in inline SVG who lives inside a soft, rounded scene and acts as an onboarding helper for storybook apps. Left alone, Pip idles forever: the eyes blink on a lazy timer, the whole body bobs gently, the tail sways, and a warm glow pulses behind it. Tapping or pressing Enter on Pip triggers a happy reaction — it waves a paw, does a little jump, the cheeks flush, and a rounded speech bubble pops up with a rotating storybook tip.
A three-button mood selector lets you swap Pip’s emotion between happy, curious, and sleepy. Each mood changes the eyes and mouth, the accent palette, and the idle animation — curious Pip tilts its head and raises an eyebrow, sleepy Pip half-closes its eyes and shows a drifting Zzz. The Guide me button launches a tiny tooltip tour that walks a glowing spotlight around the mood buttons, Pip itself, and the toggles, with Next and Done controls and arrow-key support.
Everything is keyboard reachable with visible focus rings, the speech bubble and tour use polite live regions for screen readers, and all the bounce, wiggle, and pop animations collapse to calm fades when prefers-reduced-motion is set. An easy-read toggle swaps the body font and loosens letter, word, and line spacing, and the whole scene stacks gracefully down to 360px so Pip stays huggable on tiny phones.
Illustrative kids’ UI only — fictional stories, characters, and audio.