Storybook — Kid Profile + Avatar Picker
A cheerful kid profile setup where children build a reading buddy from chunky option swatches. A live inline-SVG avatar updates instantly as you change skin tone, hair color and style, eye expression, accessory, and background, all drawn in pure CSS and SVG with no external images. A friendly name input and a tappable age picker round out the profile, a Surprise me button rolls a random look, and Save pops a confetti confirmation showing the finished avatar. Everything persists to localStorage, with an easy-read font toggle for accessibility.
MCP
Code
/* ============ Storybook — Kid Profile + Avatar Picker ============ */
:root {
--bg: #fff8ef;
--surface: #ffffff;
--ink: #2c2350;
--ink-soft: #6a6390;
--primary: #ff8a3d;
--primary-ink: #b24a00;
--secondary: #5ec5d6;
--accent: #ffd23f;
--pink: #ff6f9c;
--green: #7bd389;
--purple: #a78bfa;
--line: #efe3d2;
--shadow: 0 14px 30px rgba(44, 35, 80, .12);
--shadow-sm: 0 6px 14px rgba(44, 35, 80, .10);
--r: 24px;
--r-sm: 16px;
--display: "Baloo 2", system-ui, sans-serif;
--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(--body);
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(900px 500px at 12% -8%, #ffe9cf 0%, transparent 60%),
radial-gradient(800px 520px at 100% 6%, #d9f4f8 0%, transparent 55%),
var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
/* easy-read (dyslexia-friendly) mode */
body.easyread {
--body: "Nunito", "Comic Sans MS", system-ui, sans-serif;
letter-spacing: .035em;
word-spacing: .14em;
line-height: 1.75;
}
body.easyread .text-input,
body.easyread p,
body.easyread .swatch-label { letter-spacing: .035em; }
h1, h2, h3, legend { font-family: var(--display); margin: 0; }
.sr-only {
position: absolute; 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; z-index: 50;
background: var(--ink); color: #fff; padding: 10px 16px;
border-radius: 999px; font-weight: 700; text-decoration: none;
transition: top .18s ease;
}
.skip-link:focus { top: 12px; }
:focus-visible {
outline: 3px solid var(--ink);
outline-offset: 3px;
border-radius: 8px;
}
.wrap {
max-width: 1080px;
margin: 0 auto;
padding: 22px clamp(14px, 4vw, 30px) 60px;
}
/* ===== top bar ===== */
.topbar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 22px;
}
.logo-blob {
display: grid; place-items: center;
width: 62px; height: 62px; font-size: 34px;
background: var(--accent);
border: 4px solid #fff;
border-radius: 22px;
box-shadow: var(--shadow-sm);
transform: rotate(-6deg);
}
.topbar-text { flex: 1 1 220px; }
.topbar-text h1 { font-size: clamp(1.5rem, 4.5vw, 2.1rem); font-weight: 800; line-height: 1.15; }
.topbar-text p { margin: 2px 0 0; color: var(--ink-soft); font-weight: 700; }
.switch { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; }
.switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.switch-track {
width: 54px; height: 32px; border-radius: 999px;
background: #e7ddcd; border: 3px solid #fff; box-shadow: inset 0 1px 3px rgba(0,0,0,.12);
position: relative; transition: background .2s ease;
}
.switch-knob {
position: absolute; top: 2px; left: 2px;
width: 22px; height: 22px; border-radius: 50%;
background: #fff; box-shadow: 0 2px 5px rgba(0,0,0,.25);
transition: transform .22s cubic-bezier(.34,1.56,.64,1);
}
.switch input:checked + .switch-track { background: var(--green); }
.switch input:checked + .switch-track .switch-knob { transform: translateX(22px); }
.switch input:focus-visible + .switch-track { outline: 3px solid var(--ink); outline-offset: 3px; }
.switch-label { font-weight: 800; font-size: .95rem; color: var(--ink); }
/* ===== layout ===== */
.grid {
display: grid;
grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
gap: clamp(16px, 3vw, 26px);
align-items: start;
}
.card {
background: var(--surface);
border: 3px solid #fff;
border-radius: var(--r);
box-shadow: var(--shadow);
padding: clamp(18px, 3vw, 26px);
}
/* ===== stage ===== */
.stage {
text-align: center;
position: sticky;
top: 18px;
background: linear-gradient(180deg, #fff 0%, #fffaf2 100%);
}
.avatar-frame {
position: relative;
width: clamp(180px, 60%, 240px);
margin: 4px auto 14px;
aspect-ratio: 1;
}
.avatar-svg {
width: 100%; height: 100%; display: block;
filter: drop-shadow(0 10px 18px rgba(44, 35, 80, .18));
border-radius: 50%;
}
.avatar-sparkle {
position: absolute; font-size: 22px;
animation: twinkle 2.6s ease-in-out infinite;
}
.avatar-sparkle.s1 { top: -2px; right: 6px; }
.avatar-sparkle.s2 { bottom: 8px; left: -4px; font-size: 18px; animation-delay: 1.1s; }
@keyframes twinkle {
0%, 100% { transform: scale(1) rotate(0); opacity: .55; }
50% { transform: scale(1.25) rotate(12deg); opacity: 1; }
}
.stage-name {
font-family: var(--display);
font-weight: 800;
font-size: 1.55rem;
color: var(--primary-ink);
margin: 0;
}
.stage-age { margin: 2px 0 16px; color: var(--ink-soft); font-weight: 700; }
/* avatar pop when option changes */
.avatar-svg.bump { animation: bump .42s cubic-bezier(.34,1.56,.64,1); }
@keyframes bump {
0% { transform: scale(1); }
40% { transform: scale(1.06) rotate(-2deg); }
100% { transform: scale(1) rotate(0); }
}
/* ===== buttons ===== */
.btn {
font-family: var(--display);
font-weight: 800;
font-size: 1.02rem;
border: none;
border-radius: 999px;
padding: 13px 22px;
min-height: 50px;
cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
transition: transform .14s ease, box-shadow .14s ease, background .15s ease;
color: #fff;
}
.btn:active { transform: translateY(1px) scale(.98); }
.btn-random {
background: var(--secondary);
color: #07474f;
box-shadow: 0 6px 0 #3ba6b8;
width: 100%;
}
.btn-random:hover { transform: translateY(-2px); box-shadow: 0 8px 0 #3ba6b8; }
.btn-random:active { transform: translateY(2px); box-shadow: 0 3px 0 #3ba6b8; }
.btn-save {
background: var(--primary);
box-shadow: 0 6px 0 #d9670f;
}
.btn-save:hover { transform: translateY(-2px); box-shadow: 0 8px 0 #d9670f; }
.btn-save:active { transform: translateY(2px); box-shadow: 0 3px 0 #d9670f; }
.btn-ghost {
background: #fff;
color: var(--ink-soft);
border: 3px solid var(--line);
box-shadow: none;
}
.btn-ghost:hover { color: var(--ink); border-color: var(--primary); transform: translateY(-2px); }
/* ===== form fields ===== */
.field { margin-bottom: 18px; border: none; padding: 0; }
fieldset.field { min-width: 0; }
.field-label {
display: block;
font-family: var(--display);
font-weight: 800;
font-size: 1.08rem;
color: var(--ink);
margin-bottom: 9px;
}
legend.field-label { padding: 0; }
.text-input {
width: 100%;
font-family: var(--body);
font-weight: 700;
font-size: 1.1rem;
color: var(--ink);
padding: 14px 16px;
border: 3px solid var(--line);
border-radius: var(--r-sm);
background: #fffdf9;
transition: border-color .15s ease, box-shadow .15s ease;
}
.text-input::placeholder { color: #c3bcd6; font-weight: 600; }
.text-input:hover { border-color: #f0d8b8; }
.text-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 4px rgba(255,138,61,.2); }
.hint { display: block; margin-top: 6px; font-size: .85rem; color: var(--ink-soft); font-weight: 600; }
/* ===== age picker ===== */
.age-picker { display: flex; flex-wrap: wrap; gap: 8px; }
.age-btn {
font-family: var(--display);
font-weight: 800;
font-size: 1.1rem;
width: 50px; height: 50px;
border-radius: 16px;
border: 3px solid var(--line);
background: #fff;
color: var(--ink);
cursor: pointer;
transition: transform .12s ease, background .15s ease, border-color .15s ease, color .15s ease;
}
.age-btn:hover { transform: translateY(-2px); border-color: var(--pink); }
.age-btn[aria-pressed="true"] {
background: var(--pink);
color: #fff;
border-color: var(--pink);
box-shadow: 0 5px 0 #d94d7c;
}
/* ===== swatches ===== */
.swatches { display: flex; flex-wrap: wrap; gap: 10px; }
.swatch {
position: relative;
cursor: pointer;
border-radius: 16px;
border: 4px solid #fff;
box-shadow: 0 3px 8px rgba(44,35,80,.14);
transition: transform .13s ease, box-shadow .13s ease;
width: 50px; height: 50px;
display: grid; place-items: center;
}
.swatch:hover { transform: translateY(-3px) scale(1.04); }
.swatch[aria-checked="true"] {
outline: 4px solid var(--ink);
outline-offset: 2px;
transform: translateY(-2px) scale(1.06);
}
.swatch[aria-checked="true"]::after {
content: "✓";
position: absolute; bottom: -8px; right: -8px;
width: 22px; height: 22px;
background: var(--green); color: #fff;
border: 3px solid #fff; border-radius: 50%;
display: grid; place-items: center;
font-size: 12px; font-weight: 900;
}
.swatch:focus-visible { outline: 4px solid var(--ink); outline-offset: 2px; }
/* chip-style swatches (icon + label) */
.swatches.chips { gap: 9px; }
.swatch.chip {
width: auto; height: auto;
background: #fff;
border: 3px solid var(--line);
box-shadow: var(--shadow-sm);
padding: 9px 14px 9px 11px;
border-radius: 999px;
gap: 7px;
grid-auto-flow: column;
min-height: 48px;
}
.swatch.chip .chip-emoji { font-size: 20px; line-height: 1; }
.swatch.chip .swatch-label { font-family: var(--display); font-weight: 800; font-size: .95rem; color: var(--ink); }
.swatch.chip:hover { border-color: var(--purple); transform: translateY(-2px); }
.swatch.chip[aria-checked="true"] {
background: #f3eeff;
border-color: var(--purple);
outline: none;
transform: translateY(-2px);
}
.swatch.chip[aria-checked="true"]::after {
bottom: -6px; right: -6px; background: var(--purple);
}
/* ===== actions ===== */
.actions {
display: flex;
gap: 12px;
margin-top: 24px;
padding-top: 18px;
border-top: 3px dashed var(--line);
}
.actions .btn-ghost { flex: 0 0 auto; }
.actions .btn-save { flex: 1 1 auto; }
/* ===== modal ===== */
.modal { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 18px; }
.modal[hidden] { display: none; }
.modal-backdrop {
position: absolute; inset: 0;
background: rgba(44, 35, 80, .5);
backdrop-filter: blur(3px);
animation: fade .2s ease;
}
.modal-card {
position: relative;
background: var(--surface);
border: 4px solid #fff;
border-radius: 28px;
box-shadow: 0 24px 60px rgba(0,0,0,.3);
padding: 30px 28px 28px;
text-align: center;
max-width: 360px;
width: 100%;
overflow: hidden;
}
.modal-card.pop { animation: pop .42s cubic-bezier(.34,1.56,.64,1); }
.modal-card h2 { font-size: 1.7rem; font-weight: 800; color: var(--primary-ink); }
.modal-card p { color: var(--ink-soft); font-weight: 700; margin: 6px 0 20px; }
.modal-card .btn-save { width: 100%; }
.modal-avatar {
width: 130px; height: 130px; margin: 0 auto 14px;
}
.modal-avatar svg { width: 100%; height: 100%; border-radius: 50%; filter: drop-shadow(0 8px 16px rgba(44,35,80,.2)); }
.confetti { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.confetti i {
position: absolute; top: -14px;
width: 9px; height: 14px; border-radius: 3px;
animation: fall 1.5s linear forwards;
}
@keyframes fall {
to { transform: translateY(420px) rotate(540deg); opacity: 0; }
}
@keyframes fade { from { opacity: 0; } }
@keyframes pop { 0% { transform: scale(.7); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
/* ===== toast ===== */
.toast {
position: fixed;
left: 50%; bottom: 22px;
transform: translate(-50%, 140%);
background: var(--ink);
color: #fff;
font-weight: 800;
font-family: var(--display);
padding: 13px 22px;
border-radius: 999px;
box-shadow: var(--shadow);
z-index: 70;
transition: transform .32s cubic-bezier(.34,1.56,.64,1);
max-width: 90vw; text-align: center;
}
.toast.show { transform: translate(-50%, 0); }
/* ===== responsive ===== */
@media (max-width: 760px) {
.grid { grid-template-columns: 1fr; }
.stage { position: static; }
.avatar-frame { width: 200px; }
}
@media (max-width: 420px) {
.actions { flex-direction: column-reverse; }
.actions .btn-ghost { width: 100%; }
.age-btn, .swatch { width: 46px; }
.age-btn { height: 46px; }
.swatch { height: 46px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
}
}/* ===== Storybook — Kid Profile + Avatar Picker ===== */
(function () {
"use strict";
var STORE_KEY = "stealthis.kidProfile.v1";
/* ---------- option data ---------- */
var SKINS = [
{ id: "porcelain", color: "#ffe0c4" },
{ id: "peach", color: "#ffd0a8" },
{ id: "honey", color: "#f0b07a" },
{ id: "caramel", color: "#cf8f57" },
{ id: "cocoa", color: "#a06a3c" },
{ id: "espresso", color: "#6f4423" }
];
var HAIR_COLORS = [
{ id: "blonde", color: "#f4c95d" },
{ id: "ginger", color: "#e07b39" },
{ id: "brown", color: "#7a4a2b" },
{ id: "black", color: "#2c2535" },
{ id: "bubblegum", color: "#ff6f9c" },
{ id: "mint", color: "#5ec5d6" }
];
var HAIR_STYLES = [
{ id: "short", label: "Short", emoji: "💇" },
{ id: "curly", label: "Curly", emoji: "🌀" },
{ id: "long", label: "Long", emoji: "💁" },
{ id: "buns", label: "Buns", emoji: "🎀" },
{ id: "spiky", label: "Spiky", emoji: "⚡" },
{ id: "bald", label: "None", emoji: "🥚" }
];
var EYES = [
{ id: "happy", label: "Happy", emoji: "😄" },
{ id: "round", label: "Round", emoji: "👀" },
{ id: "wink", label: "Wink", emoji: "😉" },
{ id: "star", label: "Stars", emoji: "🤩" },
{ id: "sleepy", label: "Sleepy", emoji: "😌" }
];
var ACCESSORIES = [
{ id: "none", label: "None", emoji: "🚫" },
{ id: "glasses", label: "Glasses", emoji: "🤓" },
{ id: "bow", label: "Bow", emoji: "🎀" },
{ id: "crown", label: "Crown", emoji: "👑" },
{ id: "cap", label: "Cap", emoji: "🧢" }
];
var BGS = [
{ id: "sunny", color: "#ffd23f" },
{ id: "sky", color: "#7ec8ff" },
{ id: "mint", color: "#7bd389" },
{ id: "berry", color: "#ff8fb3" },
{ id: "grape", color: "#b79bff" },
{ id: "coral", color: "#ff9d6e" }
];
var AGES = [4, 5, 6, 7, 8, 9, 10, 11, 12];
var DEFAULT = {
name: "",
age: 7,
skin: "honey",
hairColor: "brown",
hairStyle: "curly",
eyes: "happy",
accessory: "none",
bg: "sunny"
};
var state = Object.assign({}, DEFAULT);
/* ---------- helpers ---------- */
function $(sel, root) { return (root || document).querySelector(sel); }
function find(list, id) {
for (var i = 0; i < list.length; i++) { if (list[i].id === id) return list[i]; }
return list[0];
}
function darken(hex, amt) {
var n = parseInt(hex.slice(1), 16);
var r = Math.max(0, ((n >> 16) & 255) - amt);
var g = Math.max(0, ((n >> 8) & 255) - amt);
var b = Math.max(0, (n & 255) - amt);
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
var toastTimer;
function toast(msg) {
var el = $("#toast");
el.textContent = msg;
el.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { el.classList.remove("show"); }, 2200);
}
/* ---------- SVG part builders ---------- */
function eyeSvg(kind, x) {
switch (kind) {
case "round":
return '<g><circle cx="' + x + '" cy="118" r="10" fill="#fff"/>' +
'<circle cx="' + x + '" cy="119" r="5.5" fill="#2c2350"/>' +
'<circle cx="' + (x - 2) + '" cy="116" r="1.8" fill="#fff"/></g>';
case "wink":
if (x > 120) return '<path d="M' + (x - 11) + ' 118q11 7 22 0" fill="none" stroke="#2c2350" stroke-width="4" stroke-linecap="round"/>';
return '<g><circle cx="' + x + '" cy="118" r="9" fill="#fff"/><circle cx="' + x + '" cy="119" r="5" fill="#2c2350"/><circle cx="' + (x - 2) + '" cy="116" r="1.6" fill="#fff"/></g>';
case "star":
return star(x, 118, 8, "#ffd23f", "#b58800");
case "sleepy":
return '<path d="M' + (x - 10) + ' 119q10 6 20 0" fill="none" stroke="#2c2350" stroke-width="4" stroke-linecap="round"/>';
default: // happy
return '<g><circle cx="' + x + '" cy="118" r="8.5" fill="#fff"/>' +
'<circle cx="' + x + '" cy="119" r="5" fill="#2c2350"/>' +
'<circle cx="' + (x - 2) + '" cy="116" r="1.7" fill="#fff"/></g>';
}
}
function star(cx, cy, r, fill, stroke) {
var pts = [], i, ang, rad;
for (i = 0; i < 10; i++) {
ang = (Math.PI / 5) * i - Math.PI / 2;
rad = i % 2 === 0 ? r : r * 0.45;
pts.push((cx + Math.cos(ang) * rad).toFixed(1) + "," + (cy + Math.sin(ang) * rad).toFixed(1));
}
return '<polygon points="' + pts.join(" ") + '" fill="' + fill + '" stroke="' + stroke + '" stroke-width="1.5" stroke-linejoin="round"/>';
}
function hairFrontSvg(style, color) {
var d = darken(color, 26);
switch (style) {
case "short":
return '<path d="M56 110c0-44 28-66 64-66s64 22 64 66c-10-22-30-30-30-30s4 14-6 18c-6-18-22-22-28-22s-22 4-28 22c-10-4-6-18-6-18s-20 8-30 30z" fill="' + color + '"/>' +
'<path d="M120 44c34 0 56 18 62 50-12-30-40-36-62-36s-50 6-62 36c6-32 28-50 62-50z" fill="' + d + '" opacity=".35"/>';
case "curly":
return '<g fill="' + color + '">' +
circ(64, 88, 18) + circ(86, 70, 20) + circ(116, 62, 22) + circ(150, 68, 20) + circ(176, 88, 18) +
circ(58, 116, 15) + circ(184, 116, 15) +
'</g><g fill="' + d + '" opacity=".3">' + circ(96, 66, 9) + circ(140, 66, 9) + '</g>';
case "long":
return '<path d="M52 118c0-46 30-70 68-70s68 24 68 70c-8-20-22-28-22-28s2 12-6 16c-8-16-24-20-40-20s-32 4-40 20c-8-4-6-16-6-16s-14 8-22 28z" fill="' + color + '"/>';
case "buns":
return circ(64, 78, 17, color) + circ(176, 78, 17, color) +
'<path d="M62 112c0-42 26-62 58-62s58 20 58 62c-8-18-22-24-22-24s2 10-6 14c-8-14-22-18-30-18s-22 4-30 18c-8-4-6-14-6-14s-14 6-22 24z" fill="' + color + '"/>' +
circ(64, 78, 7, d) + circ(176, 78, 7, d);
case "spiky":
return '<path d="M60 112 70 62l16 36 10-46 18 40 14-46 14 46 18-40 10 46 16-36 10 50c-12-24-148-24-150 0z" fill="' + color + '"/>';
default: // bald
return "";
}
}
function hairBackSvg(style, color) {
var d = darken(color, 18);
if (style === "long") {
return '<path d="M48 110c0 60 16 96 16 96l14-6c-6-30-8-58-6-86zM192 110c0 60-16 96-16 96l-14-6c6-30 8-58 6-86z" fill="' + d + '"/>';
}
if (style === "buns" || style === "curly") {
return '<path d="M56 116c0 26 6 44 6 44l10-4c-4-16-6-30-5-42zM184 116c0 26-6 44-6 44l-10-4c4-16 6-30 5-42z" fill="' + d + '"/>';
}
return "";
}
function circ(cx, cy, r, fill) {
return '<circle cx="' + cx + '" cy="' + cy + '" r="' + r + '"' + (fill ? ' fill="' + fill + '"' : "") + '/>';
}
function accessorySvg(kind) {
switch (kind) {
case "glasses":
return '<g fill="none" stroke="#2c2350" stroke-width="4">' +
'<rect x="74" y="106" width="32" height="26" rx="9" fill="#bfe9f0" fill-opacity=".5"/>' +
'<rect x="134" y="106" width="32" height="26" rx="9" fill="#bfe9f0" fill-opacity=".5"/>' +
'<path d="M106 116h28" /></g>';
case "bow":
return '<g transform="translate(120 52)"><path d="M0 0L-26-12 -26 12z" fill="#ff6f9c"/><path d="M0 0L26-12 26 12z" fill="#ff6f9c"/><circle r="7" fill="#ff97b6"/></g>';
case "crown":
return '<g transform="translate(120 50)"><path d="M-34 6 -34-18 -16-4 0-22 16-4 34-18 34 6z" fill="#ffd23f" stroke="#e0a800" stroke-width="2.5" stroke-linejoin="round"/>' +
'<circle cx="0" cy="-12" r="3.5" fill="#ff6f9c"/><circle cx="-22" cy="-2" r="3" fill="#5ec5d6"/><circle cx="22" cy="-2" r="3" fill="#5ec5d6"/></g>';
case "cap":
return '<g><path d="M58 96c0-36 28-56 62-56s62 20 62 54c0 6-2 8-2 8H60s-2-2-2-6z" fill="#5ec5d6"/>' +
'<path d="M178 100c22 2 38 10 38 18 0 4-4 6-10 6-8 0-18-10-34-12z" fill="#3ba6b8"/>' +
'<circle cx="120" cy="48" r="6" fill="#ffd23f"/></g>';
default:
return "";
}
}
/* ---------- render avatar into a given <svg> root ---------- */
function paintAvatar(svgRoot, st) {
var skin = find(SKINS, st.skin).color;
var skinShade = darken(skin, 26);
var hairColor = find(HAIR_COLORS, st.hairColor).color;
var bg = find(BGS, st.bg).color;
$("#head", svgRoot).setAttribute("fill", skin);
$("#ear1", svgRoot).setAttribute("fill", skin);
$("#ear2", svgRoot).setAttribute("fill", skin);
$("#bgRect", svgRoot).setAttribute("fill", bg);
$("#shoulders", svgRoot).setAttribute("fill", darken(bg, 40));
$("#eyes", svgRoot).innerHTML = eyeSvg(st.eyes, 92) + eyeSvg(st.eyes, 148);
$("#hairBack", svgRoot).innerHTML = hairBackSvg(st.hairStyle, hairColor);
$("#hairFront", svgRoot).innerHTML = hairFrontSvg(st.hairStyle, hairColor);
$("#accessory", svgRoot).innerHTML = accessorySvg(st.accessory);
// nose tint follows skin
var smile = $("#smile", svgRoot);
if (smile) smile.setAttribute("stroke", skinShade);
}
function render(animate) {
var svg = $("#avatarSvg");
paintAvatar(svg, state);
if (animate) {
svg.classList.remove("bump");
void svg.offsetWidth; // reflow to restart animation
svg.classList.add("bump");
}
$("#stageName").textContent = state.name ? "Hello, " + state.name + "!" : "Hello, friend!";
$("#stageAge").textContent = "I am " + state.age + " years old";
// sync swatch checked state
document.querySelectorAll(".swatch").forEach(function (el) {
var key = el.parentElement.getAttribute("data-key");
el.setAttribute("aria-checked", state[key] === el.dataset.val ? "true" : "false");
el.tabIndex = state[key] === el.dataset.val ? 0 : -1;
});
document.querySelectorAll(".age-btn").forEach(function (el) {
el.setAttribute("aria-pressed", String(Number(el.dataset.age) === state.age));
});
}
/* ---------- build option swatches ---------- */
function buildSwatches(key, list, colored) {
var group = document.querySelector('[data-key="' + key + '"]');
group.innerHTML = "";
list.forEach(function (opt) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "swatch" + (colored ? "" : " chip");
btn.setAttribute("role", "radio");
btn.dataset.val = opt.id;
if (colored) {
btn.style.background = opt.color;
btn.setAttribute("aria-label", opt.id);
btn.title = opt.id;
} else {
btn.innerHTML = '<span class="chip-emoji" aria-hidden="true">' + opt.emoji + '</span>' +
'<span class="swatch-label">' + opt.label + '</span>';
btn.setAttribute("aria-label", opt.label);
}
btn.addEventListener("click", function () { setOption(key, opt.id); });
group.appendChild(btn);
});
// arrow-key roving focus within the radiogroup
group.addEventListener("keydown", function (e) {
if (["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"].indexOf(e.key) === -1) return;
e.preventDefault();
var items = Array.prototype.slice.call(group.querySelectorAll(".swatch"));
var idx = items.indexOf(document.activeElement);
if (idx === -1) idx = 0;
var dir = (e.key === "ArrowRight" || e.key === "ArrowDown") ? 1 : -1;
var next = (idx + dir + items.length) % items.length;
items[next].focus();
setOption(key, items[next].dataset.val);
});
}
function setOption(key, val) {
if (state[key] === val) return;
state[key] = val;
render(true);
save();
}
/* ---------- age picker ---------- */
function buildAges() {
var picker = $("#agePicker");
AGES.forEach(function (age) {
var b = document.createElement("button");
b.type = "button";
b.className = "age-btn";
b.dataset.age = String(age);
b.textContent = String(age);
b.setAttribute("aria-pressed", "false");
b.setAttribute("aria-label", age + " years old");
b.addEventListener("click", function () {
state.age = age;
render(false);
save();
});
picker.appendChild(b);
});
}
/* ---------- persistence ---------- */
function save() {
try { localStorage.setItem(STORE_KEY, JSON.stringify(state)); } catch (e) {}
}
function load() {
try {
var raw = localStorage.getItem(STORE_KEY);
if (raw) {
var data = JSON.parse(raw);
Object.keys(DEFAULT).forEach(function (k) {
if (data[k] !== undefined && data[k] !== null) state[k] = data[k];
});
}
} catch (e) {}
}
/* ---------- randomize ---------- */
function pick(list) { return list[Math.floor(Math.random() * list.length)].id; }
function randomize() {
state.skin = pick(SKINS);
state.hairColor = pick(HAIR_COLORS);
state.hairStyle = pick(HAIR_STYLES);
state.eyes = pick(EYES);
state.accessory = pick(ACCESSORIES);
state.bg = pick(BGS);
state.age = AGES[Math.floor(Math.random() * AGES.length)];
render(true);
save();
toast("Ta-da! A brand new look 🎨");
}
/* ---------- save modal ---------- */
var lastFocused = null;
function openModal() {
// build a standalone copy of the avatar for the modal
var src = $("#avatarSvg");
var clone = src.cloneNode(true);
clone.classList.remove("bump");
clone.removeAttribute("id");
var holder = $("#modalAvatar");
holder.innerHTML = "";
holder.appendChild(clone);
var who = state.name ? state.name : "Your buddy";
$("#modalTitle").textContent = "All done, " + (state.name || "friend") + "! 🎉";
$("#modalMsg").textContent = who + " is " + state.age + " and ready for story time.";
spawnConfetti();
var modal = $("#saveModal");
lastFocused = document.activeElement;
modal.hidden = false;
$("#modalClose").focus();
document.addEventListener("keydown", onModalKey);
}
function closeModal() {
$("#saveModal").hidden = true;
document.removeEventListener("keydown", onModalKey);
if (lastFocused) lastFocused.focus();
}
function onModalKey(e) {
if (e.key === "Escape") closeModal();
if (e.key === "Tab") { e.preventDefault(); $("#modalClose").focus(); }
}
function spawnConfetti() {
var box = $("#confetti");
box.innerHTML = "";
var colors = ["#ff8a3d", "#5ec5d6", "#ffd23f", "#ff6f9c", "#7bd389", "#a78bfa"];
for (var i = 0; i < 26; i++) {
var c = document.createElement("i");
c.style.left = (Math.random() * 100) + "%";
c.style.background = colors[i % colors.length];
c.style.animationDelay = (Math.random() * 0.4) + "s";
c.style.transform = "rotate(" + (Math.random() * 360) + "deg)";
box.appendChild(c);
}
}
/* ---------- wire up ---------- */
function init() {
buildSwatches("skin", SKINS, true);
buildSwatches("hairColor", HAIR_COLORS, true);
buildSwatches("hairStyle", HAIR_STYLES, false);
buildSwatches("eyes", EYES, false);
buildSwatches("accessory", ACCESSORIES, false);
buildSwatches("bg", BGS, true);
buildAges();
load();
var nameInput = $("#kidName");
nameInput.value = state.name;
nameInput.addEventListener("input", function () {
state.name = nameInput.value.replace(/[<>]/g, "").trim();
$("#stageName").textContent = state.name ? "Hello, " + state.name + "!" : "Hello, friend!";
save();
});
$("#randomBtn").addEventListener("click", randomize);
$("#resetBtn").addEventListener("click", function () {
state = Object.assign({}, DEFAULT);
nameInput.value = "";
render(true);
save();
toast("Fresh start! 🌱");
});
$("#profileForm").addEventListener("submit", function (e) {
e.preventDefault();
if (!state.name) {
toast("Add your name first 😊");
nameInput.focus();
return;
}
save();
openModal();
});
$("#modalClose").addEventListener("click", closeModal);
$("#modalBackdrop").addEventListener("click", closeModal);
$("#dyslexia").addEventListener("change", function (e) {
document.body.classList.toggle("easyread", e.target.checked);
toast(e.target.checked ? "Easy-read font on 📖" : "Back to normal font");
});
render(false);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Storybook — Kid Profile + Avatar Picker</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="#main">Skip to builder</a>
<main id="main" class="wrap" role="main">
<header class="topbar">
<div class="logo" aria-hidden="true">
<span class="logo-blob">🦊</span>
</div>
<div class="topbar-text">
<h1>My Storybook Profile</h1>
<p>Design your very own reading buddy!</p>
</div>
<div class="topbar-controls">
<label class="switch" for="dyslexia">
<input type="checkbox" id="dyslexia" />
<span class="switch-track" aria-hidden="true"><span class="switch-knob"></span></span>
<span class="switch-label">Easy-read font</span>
</label>
</div>
</header>
<div class="grid">
<!-- ===== STAGE / PREVIEW ===== -->
<section class="stage card" aria-labelledby="stage-h">
<h2 id="stage-h" class="sr-only">Your avatar preview</h2>
<div class="avatar-frame" id="avatarFrame">
<svg viewBox="0 0 240 240" class="avatar-svg" id="avatarSvg"
role="img" aria-label="Avatar preview">
<defs>
<clipPath id="bgClip"><circle cx="120" cy="120" r="116" /></clipPath>
<radialGradient id="bgGrad" cx="50%" cy="35%" r="80%">
<stop offset="0%" stop-color="#ffffff" stop-opacity=".55" />
<stop offset="100%" stop-color="#ffffff" stop-opacity="0" />
</radialGradient>
</defs>
<g clip-path="url(#bgClip)">
<rect id="bgRect" x="0" y="0" width="240" height="240" fill="#ffd23f" />
<circle id="bgDeco1" cx="48" cy="56" r="20" fill="#ffffff" opacity=".28" />
<circle id="bgDeco2" cx="196" cy="80" r="13" fill="#ffffff" opacity=".22" />
<circle id="bgDeco3" cx="60" cy="186" r="11" fill="#ffffff" opacity=".22" />
<rect x="0" y="0" width="240" height="240" fill="url(#bgGrad)" />
</g>
<!-- shoulders -->
<path id="shoulders" d="M52 240c0-38 30-58 68-58s68 20 68 58z" fill="#5ec5d6" />
<path id="shouldersShade" d="M52 240c0-38 30-58 68-58 6 0 12 .6 17 1.7C108 188 86 214 86 240z"
fill="#000000" opacity=".08" />
<!-- hair back -->
<g id="hairBack"></g>
<!-- head -->
<ellipse id="head" cx="120" cy="118" rx="64" ry="68" fill="#ffd9b3" />
<ellipse id="ear1" cx="58" cy="124" rx="12" ry="16" fill="#ffd9b3" />
<ellipse id="ear2" cx="182" cy="124" rx="12" ry="16" fill="#ffd9b3" />
<!-- cheeks -->
<circle cx="88" cy="142" r="10" fill="#ff6f9c" opacity=".35" />
<circle cx="152" cy="142" r="10" fill="#ff6f9c" opacity=".35" />
<!-- eyes -->
<g id="eyes"></g>
<!-- nose + smile -->
<path d="M116 134q4 6 8 0" fill="none" stroke="#b07a4f" stroke-width="3" stroke-linecap="round" />
<path id="smile" d="M100 152q20 22 40 0" fill="none" stroke="#7a4a2b" stroke-width="4" stroke-linecap="round" />
<!-- hair front -->
<g id="hairFront"></g>
<!-- accessory -->
<g id="accessory"></g>
</svg>
<span class="avatar-sparkle s1" aria-hidden="true">✨</span>
<span class="avatar-sparkle s2" aria-hidden="true">⭐</span>
</div>
<p class="stage-name" id="stageName">Hello, friend!</p>
<p class="stage-age" id="stageAge">Pick your age below</p>
<button type="button" class="btn btn-random" id="randomBtn">
<span aria-hidden="true">🎲</span> Surprise me!
</button>
</section>
<!-- ===== CONTROLS ===== -->
<section class="controls card" aria-labelledby="controls-h">
<h2 id="controls-h" class="sr-only">Customize your avatar</h2>
<form id="profileForm" novalidate>
<div class="field">
<label class="field-label" for="kidName">What's your name?</label>
<input class="text-input" type="text" id="kidName" name="kidName"
maxlength="16" autocomplete="off" placeholder="Type your name…"
aria-describedby="nameHint" />
<span class="hint" id="nameHint">Up to 16 letters</span>
</div>
<div class="field">
<span class="field-label" id="ageLabel">How old are you?</span>
<div class="age-picker" role="group" aria-labelledby="ageLabel" id="agePicker"></div>
</div>
<fieldset class="field" id="skinGroup">
<legend class="field-label">Skin</legend>
<div class="swatches" role="radiogroup" aria-label="Skin color" data-key="skin"></div>
</fieldset>
<fieldset class="field" id="hairColorGroup">
<legend class="field-label">Hair color</legend>
<div class="swatches" role="radiogroup" aria-label="Hair color" data-key="hairColor"></div>
</fieldset>
<fieldset class="field" id="hairStyleGroup">
<legend class="field-label">Hair style</legend>
<div class="swatches chips" role="radiogroup" aria-label="Hair style" data-key="hairStyle"></div>
</fieldset>
<fieldset class="field" id="eyesGroup">
<legend class="field-label">Eyes</legend>
<div class="swatches chips" role="radiogroup" aria-label="Eye style" data-key="eyes"></div>
</fieldset>
<fieldset class="field" id="accessoryGroup">
<legend class="field-label">Accessory</legend>
<div class="swatches chips" role="radiogroup" aria-label="Accessory" data-key="accessory"></div>
</fieldset>
<fieldset class="field" id="bgGroup">
<legend class="field-label">Background</legend>
<div class="swatches" role="radiogroup" aria-label="Background color" data-key="bg"></div>
</fieldset>
<div class="actions">
<button type="button" class="btn btn-ghost" id="resetBtn">Start over</button>
<button type="submit" class="btn btn-save" id="saveBtn">
<span aria-hidden="true">💾</span> Save my profile
</button>
</div>
</form>
</section>
</div>
</main>
<!-- ===== SAVE CONFIRMATION ===== -->
<div class="modal" id="saveModal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" hidden>
<div class="modal-backdrop" id="modalBackdrop"></div>
<div class="modal-card pop" role="document">
<div class="confetti" id="confetti" aria-hidden="true"></div>
<div class="modal-avatar" id="modalAvatar" aria-hidden="true"></div>
<h2 id="modalTitle">All done! 🎉</h2>
<p id="modalMsg">Your reading buddy is ready.</p>
<button type="button" class="btn btn-save" id="modalClose">Let's read!</button>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Kid Profile + Avatar Picker
A playful onboarding screen where a child builds their own reading buddy. The whole avatar is a single inline SVG drawn from scratch — a friendly face with rosy cheeks, layered hair, expressive eyes, and an optional accessory sitting inside a colorful circle. Every choice is made with big, tactile swatches: six skin tones and six hair colors as color chips, plus emoji-labeled chips for hair style, eye expression, accessory, and a backdrop color. As soon as an option is tapped the avatar gently pops and repaints in real time, so kids see the change immediately.
Below the stage, a large name input and a tappable age picker (ages 4–12) complete the profile. A chunky Surprise me! button rolls a fully random look, Start over resets to the defaults, and Save my profile validates that a name is present before popping a confetti-filled confirmation dialog that shows the finished avatar and a personalized message. The dialog traps focus and closes on Escape or backdrop click.
The swatch groups are real ARIA radiogroups with arrow-key roving focus, every control has a visible focus ring and a 48px-plus touch target, and motion respects prefers-reduced-motion. An easy-read font toggle switches the body typeface and loosens spacing for dyslexia-friendly reading. The entire profile — name, age, and every avatar choice — is saved to localStorage, so the buddy is still there on the next visit, and the layout collapses to a single column down to 360px.
Illustrative kids’ UI only — fictional stories, characters, and audio.