Storybook — Drag-to-match Activity
A cheerful drag-to-match activity for early readers in the Critter Cottages storybook style. A column of inline-SVG animal friends faces a column of cozy homes, and the player drags each friend onto the right place. Correct drops snap shut with a happy pop and a gold star, while wrong ones bounce back with a gentle shake. Pointer-based dragging is mirrored by a full tap and keyboard fallback, with a running score, an all-matched confetti finale, an easy-read font switch, and a one-tap reset.
MCP
Code
:root {
--bg: #fff8ef;
--bg-2: #fff1e0;
--ink: #2c2350;
--ink-soft: #6a5f8c;
--primary: #ff8a3d;
--secondary: #5ec5d6;
--accent: #ffd23f;
--pink: #ff6f9c;
--green: #7bd389;
--card: #ffffff;
--line: #2c2350;
--r: 22px;
--r-sm: 14px;
--r-pill: 999px;
--shadow: 0 10px 0 rgba(44, 35, 80, 0.08), 0 18px 34px rgba(44, 35, 80, 0.12);
--shadow-sm: 0 6px 0 rgba(44, 35, 80, 0.07), 0 10px 20px rgba(44, 35, 80, 0.1);
--font-display: "Baloo 2", system-ui, sans-serif;
--font-body: "Nunito", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font-body);
font-weight: 600;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(circle at 12% 8%, rgba(94, 197, 214, 0.16), transparent 42%),
radial-gradient(circle at 88% 12%, rgba(255, 111, 156, 0.16), transparent 40%),
radial-gradient(circle at 50% 100%, rgba(123, 211, 137, 0.18), transparent 55%),
var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* dyslexia-friendly / easy-read mode */
body.easy-read {
font-family: "Comic Sans MS", "Trebuchet MS", var(--font-body);
letter-spacing: 0.03em;
word-spacing: 0.12em;
line-height: 1.7;
}
body.easy-read .friend-name,
body.easy-read .home-name {
letter-spacing: 0.04em;
}
h1, h2 {
font-family: var(--font-display);
margin: 0;
}
kbd {
font-family: var(--font-body);
font-weight: 800;
font-size: 0.82em;
background: var(--bg-2);
border: 2px solid var(--line);
border-radius: 7px;
padding: 0.05em 0.42em;
box-shadow: 0 2px 0 var(--line);
}
.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: -64px;
z-index: 40;
background: var(--ink);
color: #fff;
padding: 0.7rem 1.1rem;
border-radius: var(--r-pill);
font-weight: 800;
text-decoration: none;
transition: top 0.18s ease;
}
.skip-link:focus-visible {
top: 12px;
}
:focus-visible {
outline: 4px solid var(--secondary);
outline-offset: 3px;
border-radius: var(--r-sm);
}
.page {
max-width: 920px;
margin: 0 auto;
padding: clamp(1rem, 3vw, 2rem);
}
/* ---------- top bar ---------- */
.topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
background: var(--card);
border: 3px solid var(--line);
border-radius: var(--r);
padding: 0.85rem 1.1rem;
box-shadow: var(--shadow);
margin-bottom: 1.25rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.8rem;
}
.brand-mark svg {
display: block;
filter: drop-shadow(0 4px 0 rgba(44, 35, 80, 0.12));
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.brand-text strong {
font-family: var(--font-display);
font-size: 1.35rem;
font-weight: 800;
}
.brand-text span {
font-size: 0.86rem;
color: var(--ink-soft);
font-weight: 700;
}
.topbar-controls {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.score {
display: inline-flex;
align-items: baseline;
gap: 0.3rem;
background: var(--accent);
border: 3px solid var(--line);
border-radius: var(--r-pill);
padding: 0.35rem 0.95rem;
font-family: var(--font-display);
font-weight: 800;
box-shadow: var(--shadow-sm);
}
.score-star {
font-size: 1.05rem;
transform: translateY(2px);
}
.score-num {
font-size: 1.4rem;
min-width: 1ch;
text-align: center;
}
.score.bump .score-num {
animation: pop 0.45s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.score-of {
font-size: 0.9rem;
color: var(--ink-soft);
}
.btn-reset,
.btn-again,
.playAgain {
font-family: var(--font-display);
}
.btn-reset {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 48px;
padding: 0 1.1rem;
font-weight: 700;
font-size: 0.95rem;
color: var(--ink);
background: #fff;
border: 3px solid var(--line);
border-radius: var(--r-pill);
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
}
.btn-reset:hover {
background: var(--bg-2);
transform: translateY(-2px);
}
.btn-reset:active {
transform: translateY(2px);
box-shadow: 0 2px 0 rgba(44, 35, 80, 0.1);
}
.toggle-dys {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-height: 48px;
padding: 0 0.9rem 0 0.45rem;
background: #fff;
border: 3px solid var(--line);
border-radius: var(--r-pill);
font-family: var(--font-body);
font-weight: 800;
font-size: 0.85rem;
color: var(--ink);
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: background 0.14s ease, transform 0.12s ease;
}
.toggle-dys:hover {
transform: translateY(-2px);
}
.toggle-knob {
width: 40px;
height: 24px;
border-radius: var(--r-pill);
border: 2.5px solid var(--line);
background: var(--bg-2);
position: relative;
flex: none;
transition: background 0.18s ease;
}
.toggle-knob::after {
content: "";
position: absolute;
top: 1px;
left: 1px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
transition: transform 0.18s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.toggle-dys[aria-pressed="true"] .toggle-knob {
background: var(--green);
}
.toggle-dys[aria-pressed="true"] .toggle-knob::after {
transform: translateX(16px);
}
/* ---------- board ---------- */
.board {
background: transparent;
}
.how {
display: flex;
align-items: flex-start;
gap: 0.6rem;
background: var(--card);
border: 3px solid var(--line);
border-radius: var(--r);
padding: 0.85rem 1.1rem;
margin: 0 0 1.4rem;
font-size: 0.95rem;
font-weight: 700;
color: var(--ink);
box-shadow: var(--shadow-sm);
}
.how-badge {
font-size: 1.3rem;
line-height: 1;
transform: translateY(1px);
}
.columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: clamp(0.8rem, 3vw, 1.6rem);
}
.col-head {
font-size: 1.15rem;
font-weight: 800;
text-align: center;
margin-bottom: 0.85rem;
color: var(--ink);
}
.col-friends .col-head { color: var(--primary); }
.col-homes .col-head { color: var(--secondary); }
.stack {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: clamp(0.7rem, 2vw, 1rem);
}
/* ---------- friend chips (draggable) ---------- */
.friend {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 76px;
padding: 0.55rem 0.9rem;
background: var(--card);
border: 3px solid var(--line);
border-radius: var(--r);
box-shadow: var(--shadow-sm);
cursor: grab;
touch-action: none;
user-select: none;
-webkit-user-select: none;
transition: transform 0.14s ease, box-shadow 0.14s ease, border-color 0.14s ease, background 0.14s ease;
}
.friend:hover {
transform: translateY(-3px) rotate(-1deg);
}
.friend:focus-visible {
outline: 4px solid var(--secondary);
outline-offset: 3px;
}
.friend .avatar {
flex: none;
width: 56px;
height: 56px;
border-radius: 50%;
border: 3px solid var(--line);
display: grid;
place-items: center;
overflow: hidden;
}
.friend .avatar svg {
width: 100%;
height: 100%;
display: block;
}
.friend-name {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.02rem;
}
.friend-sub {
display: block;
font-size: 0.78rem;
color: var(--ink-soft);
font-weight: 700;
}
/* dragging clone & states */
.friend.dragging {
opacity: 0.35;
cursor: grabbing;
}
.friend.armed {
border-color: var(--pink);
background: #fff3f7;
transform: translateY(-2px) scale(1.02);
box-shadow: 0 0 0 4px rgba(255, 111, 156, 0.25), var(--shadow-sm);
}
.friend.shake {
animation: shake 0.45s ease;
}
.friend.done {
background: var(--bg-2);
border-style: dashed;
border-color: var(--green);
cursor: default;
opacity: 0.85;
}
.friend.done::after {
content: "✓";
margin-left: auto;
color: var(--green);
font-weight: 900;
font-size: 1.3rem;
}
.friend.hidden {
visibility: hidden;
}
.friend-ghost {
position: fixed;
z-index: 60;
pointer-events: none;
width: var(--ghost-w, 220px);
transform: rotate(-3deg) scale(1.05);
box-shadow: 0 16px 30px rgba(44, 35, 80, 0.28);
border-color: var(--pink);
background: #fff;
opacity: 0.97;
}
/* ---------- homes (drop targets) ---------- */
.home {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 76px;
padding: 0.55rem 0.9rem;
background: rgba(255, 255, 255, 0.65);
border: 3px dashed var(--line);
border-radius: var(--r);
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: transform 0.14s ease, border-color 0.14s ease, background 0.14s ease, box-shadow 0.14s ease;
}
.home:focus-visible {
outline: 4px solid var(--secondary);
outline-offset: 3px;
}
.home .home-icon {
flex: none;
width: 56px;
height: 56px;
border-radius: var(--r-sm);
border: 3px solid var(--line);
background: var(--bg-2);
display: grid;
place-items: center;
}
.home .home-icon svg {
width: 38px;
height: 38px;
display: block;
}
.home-name {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.02rem;
}
.home-sub {
display: block;
font-size: 0.78rem;
color: var(--ink-soft);
font-weight: 700;
}
.home.droppable {
border-color: var(--secondary);
border-style: solid;
transform: scale(1.02);
}
.home.over {
background: #e9fbff;
border-color: var(--secondary);
box-shadow: 0 0 0 5px rgba(94, 197, 214, 0.3), var(--shadow-sm);
transform: scale(1.04);
}
.home.armed-target {
border-color: var(--pink);
border-style: solid;
box-shadow: 0 0 0 4px rgba(255, 111, 156, 0.25), var(--shadow-sm);
}
.home.filled {
background: #fff;
border-style: solid;
border-color: var(--green);
cursor: default;
}
.home.filled .home-icon {
background: #effaf0;
}
.home.pop {
animation: pop 0.5s cubic-bezier(0.3, 1.6, 0.5, 1);
}
/* the matched friend, nested inside its home */
.home .nested {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.4rem;
}
.home .nested .mini {
width: 42px;
height: 42px;
border-radius: 50%;
border: 3px solid var(--green);
overflow: hidden;
}
.home .nested .mini svg {
width: 100%;
height: 100%;
display: block;
}
.home .nested .star {
font-size: 1.2rem;
animation: pop 0.6s cubic-bezier(0.3, 1.6, 0.5, 1);
}
/* ---------- celebration ---------- */
.celebrate {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.2rem;
background: rgba(44, 35, 80, 0.5);
backdrop-filter: blur(3px);
}
.celebrate[hidden] {
display: none;
}
.celebrate-card {
position: relative;
max-width: 380px;
width: 100%;
text-align: center;
background: var(--card);
border: 4px solid var(--line);
border-radius: 28px;
padding: 2.2rem 1.6rem 1.8rem;
box-shadow: var(--shadow);
animation: pop 0.5s cubic-bezier(0.3, 1.6, 0.5, 1);
overflow: hidden;
}
.celebrate-emoji {
font-size: 3.4rem;
line-height: 1;
}
.celebrate-card h2 {
font-size: 1.9rem;
font-weight: 800;
margin: 0.4rem 0 0.3rem;
color: var(--primary);
}
.celebrate-card p {
margin: 0 0 1.3rem;
font-weight: 700;
color: var(--ink-soft);
}
.btn-again {
min-height: 52px;
padding: 0 1.8rem;
font-weight: 800;
font-size: 1.05rem;
color: #fff;
background: var(--primary);
border: 3px solid var(--line);
border-radius: var(--r-pill);
box-shadow: 0 6px 0 rgba(44, 35, 80, 0.25);
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.btn-again:hover {
transform: translateY(-2px);
background: #ff7a23;
}
.btn-again:active {
transform: translateY(3px);
box-shadow: 0 2px 0 rgba(44, 35, 80, 0.25);
}
.confetti {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.confetti i {
position: absolute;
top: -12px;
width: 10px;
height: 14px;
border-radius: 3px;
opacity: 0.95;
animation: fall linear forwards;
}
/* ---------- toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 24px);
background: var(--ink);
color: #fff;
font-weight: 800;
font-family: var(--font-display);
padding: 0.7rem 1.3rem;
border-radius: var(--r-pill);
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
z-index: 90;
transition: opacity 0.22s ease, transform 0.22s ease;
max-width: min(90vw, 360px);
text-align: center;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- animations ---------- */
@keyframes pop {
0% { transform: scale(0.7); }
55% { transform: scale(1.14); }
100% { transform: scale(1); }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px) rotate(-2deg); }
40% { transform: translateX(8px) rotate(2deg); }
60% { transform: translateX(-6px) rotate(-1deg); }
80% { transform: translateX(6px) rotate(1deg); }
}
@keyframes fall {
to {
transform: translateY(420px) rotate(640deg);
opacity: 0;
}
}
/* ---------- responsive ---------- */
@media (max-width: 600px) {
.topbar {
justify-content: center;
text-align: center;
}
.brand {
width: 100%;
justify-content: center;
}
.topbar-controls {
width: 100%;
justify-content: center;
}
.friend-sub,
.home-sub {
display: none;
}
}
@media (max-width: 380px) {
.friend,
.home {
padding: 0.45rem 0.6rem;
gap: 0.5rem;
}
.friend .avatar,
.home .home-icon {
width: 46px;
height: 46px;
}
.friend-name,
.home-name {
font-size: 0.9rem;
}
.toggle-label {
display: none;
}
.toggle-dys {
padding: 0 0.45rem;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
.confetti {
display: none;
}
}(function () {
"use strict";
/* ---------------- data ---------------- */
// Inline-SVG animals (no external images). Each pairs with a home.
const ANIMALS = [
{
id: "bee",
name: "Buzzy",
kind: "Bee",
home: "hive",
svg:
'<svg viewBox="0 0 64 64" aria-hidden="true">' +
'<ellipse cx="32" cy="36" rx="16" ry="14" fill="#ffd23f" stroke="#2c2350" stroke-width="3"/>' +
'<path d="M22 28h20M20 36h24M22 44h20" stroke="#2c2350" stroke-width="3" stroke-linecap="round"/>' +
'<ellipse cx="22" cy="22" rx="9" ry="6" fill="#dff6fb" stroke="#2c2350" stroke-width="2.5"/>' +
'<ellipse cx="42" cy="22" rx="9" ry="6" fill="#dff6fb" stroke="#2c2350" stroke-width="2.5"/>' +
'<circle cx="28" cy="33" r="2.4" fill="#2c2350"/><circle cx="36" cy="33" r="2.4" fill="#2c2350"/>' +
"</svg>"
},
{
id: "fish",
name: "Bubbles",
kind: "Fish",
home: "pond",
svg:
'<svg viewBox="0 0 64 64" aria-hidden="true">' +
'<path d="M46 32 60 22v20z" fill="#5ec5d6" stroke="#2c2350" stroke-width="3" stroke-linejoin="round"/>' +
'<ellipse cx="28" cy="32" rx="20" ry="13" fill="#5ec5d6" stroke="#2c2350" stroke-width="3"/>' +
'<circle cx="20" cy="29" r="3" fill="#fff" stroke="#2c2350" stroke-width="2"/>' +
'<circle cx="20" cy="29" r="1.4" fill="#2c2350"/>' +
'<path d="M30 26q6 6 0 12" fill="none" stroke="#2c2350" stroke-width="2.5"/>' +
"</svg>"
},
{
id: "owl",
name: "Hoot",
kind: "Owl",
home: "tree",
svg:
'<svg viewBox="0 0 64 64" aria-hidden="true">' +
'<path d="M18 20q14-12 28 0v22a14 14 0 0 1-28 0z" fill="#c79a6b" stroke="#2c2350" stroke-width="3"/>' +
'<circle cx="26" cy="30" r="7" fill="#fff" stroke="#2c2350" stroke-width="2.5"/>' +
'<circle cx="38" cy="30" r="7" fill="#fff" stroke="#2c2350" stroke-width="2.5"/>' +
'<circle cx="26" cy="30" r="3" fill="#2c2350"/><circle cx="38" cy="30" r="3" fill="#2c2350"/>' +
'<path d="M29 37l3 4 3-4z" fill="#ff8a3d" stroke="#2c2350" stroke-width="2"/>' +
"</svg>"
},
{
id: "frog",
name: "Hops",
kind: "Frog",
home: "lilypad",
svg:
'<svg viewBox="0 0 64 64" aria-hidden="true">' +
'<ellipse cx="32" cy="40" rx="18" ry="14" fill="#7bd389" stroke="#2c2350" stroke-width="3"/>' +
'<circle cx="22" cy="22" r="8" fill="#7bd389" stroke="#2c2350" stroke-width="3"/>' +
'<circle cx="42" cy="22" r="8" fill="#7bd389" stroke="#2c2350" stroke-width="3"/>' +
'<circle cx="22" cy="21" r="3" fill="#2c2350"/><circle cx="42" cy="21" r="3" fill="#2c2350"/>' +
'<path d="M24 44q8 6 16 0" fill="none" stroke="#2c2350" stroke-width="3" stroke-linecap="round"/>' +
"</svg>"
},
{
id: "bunny",
name: "Thistle",
kind: "Bunny",
home: "burrow",
svg:
'<svg viewBox="0 0 64 64" aria-hidden="true">' +
'<ellipse cx="25" cy="20" rx="5" ry="12" fill="#ffe1ec" stroke="#2c2350" stroke-width="3"/>' +
'<ellipse cx="39" cy="20" rx="5" ry="12" fill="#ffe1ec" stroke="#2c2350" stroke-width="3"/>' +
'<circle cx="32" cy="40" r="16" fill="#fff" stroke="#2c2350" stroke-width="3"/>' +
'<circle cx="26" cy="38" r="2.6" fill="#2c2350"/><circle cx="38" cy="38" r="2.6" fill="#2c2350"/>' +
'<path d="M30 45l2 2 2-2z" fill="#ff6f9c" stroke="#2c2350" stroke-width="1.6"/>' +
"</svg>"
}
];
const HOME_ART = {
hive:
'<svg viewBox="0 0 48 48" aria-hidden="true"><path d="M10 14h28v6H10zM8 22h32v6H8zM10 30h28v6H10zM12 38h24v4H12z" fill="#ffd23f" stroke="#2c2350" stroke-width="2.4"/><circle cx="24" cy="33" r="3" fill="#2c2350"/></svg>',
pond:
'<svg viewBox="0 0 48 48" aria-hidden="true"><ellipse cx="24" cy="30" rx="18" ry="11" fill="#5ec5d6" stroke="#2c2350" stroke-width="2.6"/><path d="M14 28q4 3 8 0t8 0" fill="none" stroke="#2c2350" stroke-width="2.2" stroke-linecap="round"/></svg>',
tree:
'<svg viewBox="0 0 48 48" aria-hidden="true"><circle cx="24" cy="18" r="13" fill="#7bd389" stroke="#2c2350" stroke-width="2.6"/><rect x="20" y="28" width="8" height="14" rx="2" fill="#c79a6b" stroke="#2c2350" stroke-width="2.4"/></svg>',
lilypad:
'<svg viewBox="0 0 48 48" aria-hidden="true"><path d="M24 12a12 12 0 1 1-3 23.7L24 24z" fill="#7bd389" stroke="#2c2350" stroke-width="2.6"/><circle cx="20" cy="14" r="3.5" fill="#ff6f9c" stroke="#2c2350" stroke-width="2"/></svg>',
burrow:
'<svg viewBox="0 0 48 48" aria-hidden="true"><path d="M6 38h36v4H6z" fill="#c79a6b" stroke="#2c2350" stroke-width="2.4"/><path d="M16 38a8 8 0 0 1 16 0z" fill="#2c2350"/></svg>'
};
const HOMES = [
{ id: "hive", name: "The Hive", sub: "Sweet & golden" },
{ id: "pond", name: "The Pond", sub: "Cool & splashy" },
{ id: "tree", name: "The Tall Tree", sub: "High & leafy" },
{ id: "lilypad", name: "The Lily Pad", sub: "Floaty & green" },
{ id: "burrow", name: "The Burrow", sub: "Cozy & snug" }
];
/* ---------------- helpers ---------------- */
const $ = (sel, root) => (root || document).querySelector(sel);
const shuffle = (arr) => {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
};
const friendStack = $("#friendStack");
const homeStack = $("#homeStack");
const scoreNum = $("#scoreNum");
const scoreTotal = $("#scoreTotal");
const scoreWrap = $(".score");
const liveRegion = $("#liveRegion");
const toastEl = $("#toast");
const celebrate = $("#celebrate");
let matched = 0;
let armed = null; // keyboard / tap pickup: the currently selected friend el
let toastTimer = null;
function announce(msg) {
liveRegion.textContent = "";
// force re-read
requestAnimationFrame(() => {
liveRegion.textContent = msg;
});
}
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 1800);
}
/* ---------------- build the board ---------------- */
function build() {
matched = 0;
armed = null;
friendStack.innerHTML = "";
homeStack.innerHTML = "";
scoreTotal.textContent = String(ANIMALS.length);
updateScore(0);
shuffle(ANIMALS).forEach((a) => friendStack.appendChild(makeFriend(a)));
shuffle(HOMES).forEach((h) => homeStack.appendChild(makeHome(h)));
}
function makeFriend(a) {
const li = document.createElement("li");
li.className = "friend";
li.dataset.home = a.home;
li.dataset.id = a.id;
li.tabIndex = 0;
li.setAttribute("role", "button");
li.setAttribute(
"aria-label",
a.name + " the " + a.kind + ". Press Enter to pick up."
);
li.innerHTML =
'<span class="avatar">' + a.svg + "</span>" +
'<span class="friend-text"><span class="friend-name">' +
a.name +
'</span><span class="friend-sub">' +
a.kind +
"</span></span>";
return li;
}
function makeHome(h) {
const li = document.createElement("li");
li.className = "home";
li.dataset.home = h.id;
li.tabIndex = 0;
li.setAttribute("role", "button");
li.setAttribute("aria-label", h.name + ". Empty. Drop a friend here.");
li.innerHTML =
'<span class="home-icon">' + (HOME_ART[h.id] || "") + "</span>" +
'<span class="home-text"><span class="home-name">' +
h.name +
'</span><span class="home-sub">' +
h.sub +
"</span></span>";
return li;
}
function updateScore(n) {
matched = n;
scoreNum.textContent = String(n);
scoreWrap.classList.remove("bump");
void scoreWrap.offsetWidth;
scoreWrap.classList.add("bump");
}
/* ---------------- match resolution ---------------- */
function isFilled(home) {
return home.classList.contains("filled");
}
function attemptMatch(friend, home) {
if (!friend || !home || isFilled(home)) return false;
if (friend.dataset.home === home.dataset.home) {
acceptMatch(friend, home);
return true;
}
rejectMatch(friend, home);
return false;
}
function acceptMatch(friend, home) {
const name = $(".friend-name", friend).textContent;
const avatarSvg = $(".avatar", friend).innerHTML;
home.classList.add("filled", "pop");
home.classList.remove("over", "droppable", "armed-target");
home.setAttribute(
"aria-label",
home.querySelector(".home-name").textContent + ". Home for " + name + ". Matched."
);
// nest a mini avatar + star inside the home
const nest = document.createElement("span");
nest.className = "nested";
nest.innerHTML =
'<span class="mini">' + avatarSvg + '</span><span class="star">⭐</span>';
home.appendChild(nest);
setTimeout(() => home.classList.remove("pop"), 520);
friend.classList.add("done");
friend.classList.remove("armed", "dragging");
friend.removeAttribute("tabindex");
friend.setAttribute("aria-label", name + " is home! Matched.");
updateScore(matched + 1);
announce(name + " found their home. " + matched + " of " + ANIMALS.length + " matched.");
toast("Yay! " + name + " is home 🎉");
if (matched === ANIMALS.length) {
setTimeout(showCelebration, 650);
}
}
function rejectMatch(friend, home) {
friend.classList.remove("shake");
void friend.offsetWidth;
friend.classList.add("shake");
home.classList.remove("over", "armed-target");
setTimeout(() => friend.classList.remove("shake"), 460);
announce("Not quite — try another home.");
toast("Hmm, that's not the right home. Try again!");
}
/* ---------------- pointer drag & drop ---------------- */
let drag = null;
function onPointerDown(e) {
const friend = e.target.closest(".friend");
if (!friend || friend.classList.contains("done")) return;
// only primary button / touch
if (e.button !== undefined && e.button !== 0) return;
clearArmed();
e.preventDefault();
const rect = friend.getBoundingClientRect();
const ghost = friend.cloneNode(true);
ghost.classList.add("friend-ghost");
ghost.classList.remove("dragging", "armed", "shake");
ghost.style.setProperty("--ghost-w", rect.width + "px");
document.body.appendChild(ghost);
drag = {
friend,
ghost,
offX: e.clientX - rect.left,
offY: e.clientY - rect.top,
pointerId: e.pointerId,
moved: false,
lastHome: null
};
friend.classList.add("dragging");
moveGhost(e.clientX, e.clientY);
highlightTargets(true);
friend.setPointerCapture && friend.setPointerCapture(e.pointerId);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerUp);
}
function moveGhost(x, y) {
drag.ghost.style.left = x - drag.offX + "px";
drag.ghost.style.top = y - drag.offY + "px";
}
function homeUnderPoint(x, y) {
drag.ghost.style.visibility = "hidden";
const el = document.elementFromPoint(x, y);
drag.ghost.style.visibility = "";
const home = el && el.closest ? el.closest(".home") : null;
if (home && !isFilled(home)) return home;
return null;
}
function onPointerMove(e) {
if (!drag) return;
drag.moved = true;
moveGhost(e.clientX, e.clientY);
const home = homeUnderPoint(e.clientX, e.clientY);
if (home !== drag.lastHome) {
if (drag.lastHome) drag.lastHome.classList.remove("over");
if (home) home.classList.add("over");
drag.lastHome = home;
}
}
function onPointerUp(e) {
if (!drag) return;
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
const { friend, ghost } = drag;
const home = drag.lastHome || homeUnderPoint(e.clientX, e.clientY);
ghost.remove();
friend.classList.remove("dragging");
highlightTargets(false);
if (home) home.classList.remove("over");
const d = drag;
drag = null;
if (home && d.moved) {
attemptMatch(friend, home);
} else if (!d.moved) {
// treat as a tap → arm for keyboard-style placement
armFriend(friend);
}
}
function highlightTargets(on) {
homeStack.querySelectorAll(".home").forEach((h) => {
if (isFilled(h)) return;
h.classList.toggle("droppable", on);
});
}
/* ---------------- keyboard / tap fallback ---------------- */
function armFriend(friend) {
if (friend.classList.contains("done")) return;
clearArmed();
armed = friend;
friend.classList.add("armed");
homeStack.querySelectorAll(".home").forEach((h) => {
if (!isFilled(h)) h.classList.add("armed-target");
});
const name = $(".friend-name", friend).textContent;
announce(name + " picked up. Now choose a home.");
toast("Picked up " + name + " — now tap a home!");
}
function clearArmed() {
if (armed) armed.classList.remove("armed");
armed = null;
homeStack.querySelectorAll(".home").forEach((h) =>
h.classList.remove("armed-target")
);
}
function placeOnHome(home) {
if (!armed) {
announce("Pick up a friend first.");
toast("Tap a friend first 🐾");
return;
}
const friend = armed;
const ok = attemptMatch(friend, home);
clearArmed();
if (!ok && !isFilled(home)) {
// wrong target: keep the friend available, re-arm nothing
}
}
function onFriendKey(e) {
const friend = e.target.closest(".friend");
if (!friend) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (armed === friend) {
clearArmed();
announce("Put down.");
} else {
armFriend(friend);
}
} else if (e.key === "Escape") {
clearArmed();
}
}
function onHomeKey(e) {
const home = e.target.closest(".home");
if (!home || isFilled(home)) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
placeOnHome(home);
}
}
/* ---------------- celebration ---------------- */
function showCelebration() {
spawnConfetti();
celebrate.hidden = false;
announce("All matched! You found a home for every friend.");
const btn = $("#playAgainBtn");
btn && btn.focus();
}
function hideCelebration() {
celebrate.hidden = true;
$("#confetti").innerHTML = "";
}
function spawnConfetti() {
const wrap = $("#confetti");
wrap.innerHTML = "";
const colors = ["#ff8a3d", "#5ec5d6", "#ffd23f", "#ff6f9c", "#7bd389"];
if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return;
}
for (let i = 0; i < 40; i++) {
const p = document.createElement("i");
p.style.left = Math.random() * 100 + "%";
p.style.background = colors[i % colors.length];
p.style.animationDuration = 1.6 + Math.random() * 1.4 + "s";
p.style.animationDelay = Math.random() * 0.5 + "s";
p.style.transform = "rotate(" + Math.random() * 360 + "deg)";
wrap.appendChild(p);
}
}
/* ---------------- wiring ---------------- */
friendStack.addEventListener("pointerdown", onPointerDown);
friendStack.addEventListener("keydown", onFriendKey);
homeStack.addEventListener("keydown", onHomeKey);
// click/tap a home (works for mouse + as fallback if pointerup missed)
homeStack.addEventListener("click", (e) => {
const home = e.target.closest(".home");
if (home && armed) placeOnHome(home);
});
// tap a friend with mouse click (in case pointer events flagged moved oddly)
friendStack.addEventListener("click", (e) => {
const friend = e.target.closest(".friend");
if (!friend || friend.classList.contains("done")) return;
// pointerdown already handled tap-arm for touch; guard double-arm
if (drag) return;
});
$("#resetBtn").addEventListener("click", () => {
hideCelebration();
build();
toast("Fresh round — let's play! 🌟");
announce("New round started. Match every friend to their home.");
});
$("#playAgainBtn").addEventListener("click", () => {
hideCelebration();
build();
const first = friendStack.querySelector(".friend");
first && first.focus();
});
// dyslexia-friendly / easy-read toggle
const dysToggle = $("#dysToggle");
dysToggle.addEventListener("click", () => {
const on = dysToggle.getAttribute("aria-pressed") === "true";
dysToggle.setAttribute("aria-pressed", String(!on));
document.body.classList.toggle("easy-read", !on);
toast(!on ? "Easy-read font on 📖" : "Easy-read font off");
});
// close celebration on Escape
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !celebrate.hidden) hideCelebration();
});
build();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Storybook — Drag-to-match Activity</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700;800&family=Nunito:wght@400;600;700;800&display=swap" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#playground">Skip to the game</a>
<div class="page">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 48 48" width="44" height="44" role="img" aria-hidden="true">
<circle cx="24" cy="24" r="22" fill="#ffd23f" />
<circle cx="24" cy="24" r="22" fill="none" stroke="#2c2350" stroke-width="3" />
<circle cx="17" cy="21" r="3.4" fill="#2c2350" />
<circle cx="31" cy="21" r="3.4" fill="#2c2350" />
<path d="M15 30 q9 9 18 0" fill="none" stroke="#2c2350" stroke-width="3.4" stroke-linecap="round" />
</svg>
</span>
<div class="brand-text">
<strong>Critter Cottages</strong>
<span>Match each friend to their home</span>
</div>
</div>
<div class="topbar-controls">
<div class="score" role="status" aria-live="polite">
<span class="score-star" aria-hidden="true">⭐</span>
<span class="score-num" id="scoreNum">0</span>
<span class="score-of">/ <span id="scoreTotal">5</span></span>
</div>
<button type="button" class="toggle-dys" id="dysToggle" aria-pressed="false">
<span class="toggle-knob" aria-hidden="true"></span>
<span class="toggle-label">Easy-read font</span>
</button>
<button type="button" class="btn-reset" id="resetBtn">
<span aria-hidden="true">🔄</span> Start over
</button>
</div>
</header>
<main id="playground" class="board">
<p class="how" id="howto">
<span class="how-badge" aria-hidden="true">👆</span>
Drag a friend to their home — or tap a friend, then tap a home. Keyboard:
use <kbd>Tab</kbd>, press <kbd>Enter</kbd> to pick up, then <kbd>Enter</kbd> on a home.
</p>
<div class="columns">
<section class="col col-friends" aria-labelledby="friendsHead">
<h2 id="friendsHead" class="col-head">Friends</h2>
<ul class="stack" id="friendStack" role="list"></ul>
</section>
<section class="col col-homes" aria-labelledby="homesHead">
<h2 id="homesHead" class="col-head">Homes</h2>
<ul class="stack" id="homeStack" role="list"></ul>
</section>
</div>
</main>
</div>
<!-- Celebration overlay -->
<div class="celebrate" id="celebrate" role="dialog" aria-modal="true" aria-labelledby="celebTitle" hidden>
<div class="celebrate-card">
<div class="confetti" id="confetti" aria-hidden="true"></div>
<div class="celebrate-emoji" aria-hidden="true">🎉</div>
<h2 id="celebTitle">All matched!</h2>
<p>You found a cozy home for every friend. Great job, story-builder!</p>
<button type="button" class="btn-again" id="playAgainBtn">Play again</button>
</div>
</div>
<!-- live region for drag/keyboard announcements -->
<p class="sr-only" id="liveRegion" aria-live="assertive"></p>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Drag-to-match Activity
A friendly two-column matching game built for tiny hands. On the left, five animal friends — Buzzy the bee, Bubbles the fish, Hoot the owl, Hops the frog, and Thistle the bunny — wait in soft, rounded cards drawn entirely as inline SVG. On the right sit their homes: the hive, the pond, the tall tree, the lily pad, and the cozy burrow, each shuffled fresh every round. The whole board uses the storybook palette of warm cream, orange, teal, and gold with thick playful borders and bouncy shadows.
Players drag a friend onto a home using pointer events that work the same with a mouse or a finger: a tilted ghost card follows the cursor, candidate homes light up, and the one under the pointer glows. Drop on the right home and it snaps closed with a pop animation, nests a mini portrait of the animal, plants a gold star, and ticks the score up; drop on the wrong one and the friend shakes its head and waits to be tried again. Every interaction is mirrored for accessibility — tap a friend then tap a home, or Tab to a friend, press Enter to pick it up, and Enter again on a home to place it. A live region narrates each move.
When all five friends are home, a confetti celebration pops up with a play-again button. A header keeps the score, an easy-read font toggle swaps to a wider, more legible typeface with looser spacing for dyslexia-friendly reading, and a start-over button reshuffles for a brand-new round. The layout collapses to a tight single-scroll board and respects reduced-motion preferences down to 360px.
Illustrative kids’ UI only — fictional stories, characters, and audio.