
Geist Learning Lab
- 103 installs
- 16 repo stars
- Updated February 10, 2026
- vercel-labs/skill-geist-learning-labs
Build explorative, interactive learning experiences as Next.
About
Build explorative, interactive learning experiences as Next.js apps using the Geist design system. Use when creating tutorials, explorable explanations, interactive lessons, code sandboxes, quizzes, or any educational UI. Covers the Learning Loop pedagogy, 23+ learning component patterns, progress tracking, spaced repetition, and Bret-Victor-style interactive exploration — all with Geist's dark-first minimal aesthetic. Build interactive learning experiences that teach by making the learner *do something, get feedback, and iterate* — using Geist's dark-first, minimal, precise UI language.
- Build interactive learning experiences that teach by making the learner *do something, get feedback, and iterate* — usin
- **Learning loops, not pages** — Every screen completes: prompt → attempt → feedback → refinement → checkpoint
- **Cognitive load management** — Progressive disclosure, chunking, one concept + one action at a time
- **Error-friendly design** — Mistakes are expected; the UI normalizes them and turns them into learning moments
- **Interactive representations** — Sliders, toggles, diagrams, live code — not just text
Geist Learning Lab by the numbers
- 103 all-time installs (skills.sh)
- +3 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,050 of 2,277 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 27, 2026 (Skillselion catalog sync)
geist-learning-lab capabilities & compatibility
- Capabilities
- build interactive learning experiences that teac · **learning loops, not pages** — every screen com · **cognitive load management** — progressive disc · **error friendly design** — mistakes are expecte
- Use cases
- documentation
What geist-learning-lab says it does
Build explorative, interactive learning experiences as Next.js apps using the Geist design system. Use when creating tutorials, explorable explanations, interactive lessons, code sandboxes, quizzes, o
npx skills add https://github.com/vercel-labs/skill-geist-learning-labs --skill geist-learning-labAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 103 |
|---|---|
| repo stars | ★ 16 |
| Security audit | 3 / 3 scanners passed |
| Last updated | February 10, 2026 |
| Repository | vercel-labs/skill-geist-learning-labs ↗ |
How do I apply geist-learning-lab using the workflow in its SKILL.md?
Build explorative, interactive learning experiences as Next.js apps using the Geist design system. Use when creating tutorials, explorable explanations, interactive lessons, code sandboxe...
Who is it for?
Developers following the geist-learning-lab skill for the tasks it documents.
Skip if: Tasks outside the geist-learning-lab scope described in SKILL.md.
When should I use this skill?
User mentions geist-learning-lab or related triggers from the skill description.
What you get
Working geist-learning-lab setup aligned with the documented patterns and constraints.
Files
Geist Learning Lab
Build interactive learning experiences that teach by making the learner do something, get feedback, and iterate — using Geist's dark-first, minimal, precise UI language.
Design Philosophy
- Learning loops, not pages — Every screen completes: prompt → attempt → feedback → refinement → checkpoint
- Cognitive load management — Progressive disclosure, chunking, one concept + one action at a time
- Error-friendly design — Mistakes are expected; the UI normalizes them and turns them into learning moments
- Interactive representations — Sliders, toggles, diagrams, live code — not just text
- Geist precision — Dark backgrounds, tight tracking, semantic color, 4px grid, Swiss typography
Critical Rules
1. Every lesson must include at least one attempt+feedback loop — No passive-only pages 2. Every interactive control must have a visible effect — Within ~100ms or show loading state 3. Progress/review must exist even in a prototype — Track steps completed, checkpoints passed 4. Active attempt before explanation — Ask a question or show a task, then explain 5. Immediate, specific feedback — "Correct" isn't enough; explain why 6. Multiple representations — Always at least 2 of: text, code, visual, interactive, real data 7. Color is learning signal — Green=correct, Red=incorrect, Amber=hint/warning, Blue=info/definition 8. Progressive disclosure by default — Depth behind "Why?", "Edge Cases", "Formal Definition" 9. URL state for exploration — Shareable slider/toggle configurations via search params 10. Misconception-first design — Bake in common traps: "Most people get this wrong because..."
The Learning Loop (Central Doctrine)
Every lesson repeats this cycle:
| Step | What Happens | Component |
|---|---|---|
| Orient | What you'll learn in 5-10 min | Heading + learning objective |
| Attempt | Learner predicts/answers/edits first | QuickCheck, CodePlayground, ParameterDock |
| Feedback | Immediate + specific response | Feedback surface (green/red panel) |
| Explain | Short explanation anchored to their attempt | Callout, WorkedExample |
| Extend | Variation, edge case, or transfer task | BeforeAfterSplit, ConceptExplorer |
| Checkpoint | Quick recall question | QuickCheck with ConfidenceRating |
| Reflect | "What changed in your understanding?" | Optional text input or self-rating |
"Aha Moment" Design Patterns
| Pattern | How It Works | When to Use |
|---|---|---|
| Prediction Gap | "What do you think happens if...?" then reveal | Before introducing a concept |
| Contrast Pair | Two near-identical cases with different outcomes | Clarifying subtle distinctions |
| Slider to Failure | Let learner push a parameter until it breaks | Understanding boundaries/limits |
| Time Travel | Scrub algorithm step-by-step, watch invariants | Algorithms, state machines |
| Misconception Trap | Tempting wrong option, then explain why wrong | Common errors in a domain |
Quick Color Reference (Learning Semantics)
| Signal | Color Token | Hex | Usage |
|---|---|---|---|
| Correct | green-700 | #46A758 | Success feedback, completed steps |
| Incorrect | red-700 | #E5484D | Error feedback, failed checks |
| Hint/Caution | amber-700 | #FFB224 | Hints, warnings, "watch out" |
| Info/Definition | blue-700 | #0070F3 | Definitions, current step, links |
| Default | gray-400 | #737373 | Everything else: borders, muted text |
| Progress done | green-700 dot | #46A758 | Completed step indicator |
| Progress current | blue-700 ring | #0070F3 | Current step focus state |
| Progress pending | gray-400 dot | #737373 | Upcoming steps |
Component Library (23 Patterns)
Lesson Flow & Progress
<LessonShell /> <ProgressRail /> <StepFlow /> <CheckpointCard /> <ResumeBanner />
Explorable Explanations
<ParameterDock /> <LiveOutputPanel /> <ConceptExplorer /> <InteractiveDiagram /> <BeforeAfterSplit /> <TimelineExplorer /> <ConceptMap />
Code Learning
<CodePlayground /> <DiffEditor /> <TaskRunner /> <SnippetCopy />
Quizzes & Active Recall
<QuickCheck /> <ConfidenceRating /> <HintLadder /> <MistakeAnalyzer />
Reading Support
<GlossaryPopover /> <Callout /> <WorkedExample />
Typography in Learning Context
| Element | What It Means | Style |
|---|---|---|
| Big heading | "What you're doing" | `text-heading-{32\ |
| Small label | "Where you are" | `text-label-{14\ |
| Mono | "What the computer sees" | text-copy-14-mono or text-label-14-mono |
| Body | Reading content | text-copy-14 or text-copy-16, max-w-2xl |
Layout Patterns
| Context | Max Width | Structure |
|---|---|---|
| Reading content | max-w-2xl / max-w-4xl | Single column, comfortable line length |
| Interactive lab | max-w-6xl | Two panels: explanation + playground |
| Full explorer | max-w-7xl | Three zones: nav + content + lab panel |
App Router Structure
/ → Landing / course picker
/learn → Course catalog + resume
/learn/[courseSlug] → Course overview, modules, progress
/learn/[courseSlug]/[moduleSlug] → Module overview
/learn/[courseSlug]/[moduleSlug]/[lessonSlug] → Lesson shellFile Organization
content/courses/<courseSlug>/course.json → Metadata, module order
content/courses/<courseSlug>/<module>/<lesson>.mdx → Lesson content
components/learning/ → Reusable learning components
lib/learning/progress.ts → Schema + storage adapters
lib/learning/grading.ts → Quiz validation, code tests
lib/learning/spaced.ts → Review schedulingReferences
references/lesson-shell.md— LessonShell, ProgressRail, StepFlow, CheckpointCard, ResumeBanner TSX patternsreferences/explorable-explanations.md— ParameterDock, LiveOutputPanel, ConceptExplorer, InteractiveDiagram, BeforeAfterSplit, TimelineExplorer, ConceptMapreferences/code-learning.md— CodePlayground (read-only/guided/free), DiffEditor, TaskRunner, SnippetCopyreferences/quiz-assessment.md— QuickCheck, ConfidenceRating, HintLadder, MistakeAnalyzer with feedback patternsreferences/reading-support.md— GlossaryPopover, Callout variants, WorkedExample with stepwise revealreferences/pedagogical-framework.md— The Learning Loop doctrine, progressive disclosure rules, aha patterns, scaffolded complexityreferences/architecture.md— Next.js App Router routing, MDX content system, state management, file organizationreferences/progress-state.md— Progress schema, localStorage adapter, spaced repetition, URL state for explorationsreferences/learning-animations.md— Step transitions, feedback reveals, diagram animations, reduced-motion compliance
Architecture & Patterns
Next.js App Router structure, MDX content system, state management, and file organization for learning apps.
App Router Structure
Route Hierarchy
app/
├── layout.tsx → Root layout (fonts, theme, global providers)
├── page.tsx → Landing / course picker
├── learn/
│ ├── layout.tsx → Learn layout (catalog chrome)
│ ├── page.tsx → Course catalog + resume banner
│ └── [courseSlug]/
│ ├── layout.tsx → Course layout (progress rail sidebar)
│ ├── page.tsx → Course overview (modules, progress, review queue)
│ ├── review/
│ │ └── page.tsx → Spaced repetition review page
│ └── [moduleSlug]/
│ ├── layout.tsx → Module layout (optional)
│ ├── page.tsx → Module overview
│ └── [lessonSlug]/
│ └── page.tsx → Lesson shell + contentLayout Nesting
Each layout adds a layer of chrome:
// app/layout.tsx — Root: fonts, theme, global CSS
import localFont from "next/font/local";
const geistSans = localFont({ src: "./fonts/GeistVF.woff", variable: "--font-sans" });
const geistMono = localFont({ src: "./fonts/GeistMonoVF.woff", variable: "--font-mono" });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body className={`${geistSans.variable} ${geistMono.variable} font-sans bg-background-100 text-gray-1000 antialiased`}>
{children}
</body>
</html>
);
}// app/learn/[courseSlug]/layout.tsx — Course: sidebar + progress rail
import { ProgressRail } from "@/components/learning/ProgressRail";
export default function CourseLayout({
children,
params,
}: {
children: React.ReactNode;
params: { courseSlug: string };
}) {
// Load course data, modules, progress
return (
<div className="flex min-h-screen">
<aside className="hidden lg:block w-64 border-r border-gray-400 bg-background-100">
<ProgressRail modules={modules} currentLesson={currentLesson} />
</aside>
<main className="flex-1">{children}</main>
</div>
);
}Content System: MDX
File Structure
content/
├── courses/
│ ├── binary-search/
│ │ ├── course.json → { title, description, modules: [...] }
│ │ ├── basics/
│ │ │ ├── what-is-binary-search.mdx
│ │ │ ├── the-algorithm.mdx
│ │ │ └── edge-cases.mdx
│ │ └── advanced/
│ │ ├── lower-bound.mdx
│ │ └── rotated-arrays.mdx
│ └── react-hooks/
│ ├── course.json
│ └── ...course.json Schema
{
"title": "Binary Search",
"description": "Master the divide-and-conquer search algorithm.",
"modules": [
{
"slug": "basics",
"title": "Fundamentals",
"lessons": [
{ "slug": "what-is-binary-search", "title": "What Is Binary Search?" },
{ "slug": "the-algorithm", "title": "The Algorithm" },
{ "slug": "edge-cases", "title": "Edge Cases" }
]
},
{
"slug": "advanced",
"title": "Advanced Patterns",
"lessons": [
{ "slug": "lower-bound", "title": "Lower Bound" },
{ "slug": "rotated-arrays", "title": "Rotated Arrays" }
]
}
]
}MDX Component Mapping
// lib/mdx-components.tsx
import { CodePlayground } from "@/components/learning/CodePlayground";
import { QuickCheck } from "@/components/learning/QuickCheck";
import { Callout } from "@/components/learning/Callout";
import { ParameterDock } from "@/components/learning/ParameterDock";
import { CheckpointCard } from "@/components/learning/CheckpointCard";
import { GlossaryPopover } from "@/components/learning/GlossaryPopover";
import { WorkedExample } from "@/components/learning/WorkedExample";
import { BeforeAfterSplit } from "@/components/learning/BeforeAfterSplit";
import { HintLadder } from "@/components/learning/HintLadder";
import { DiffEditor } from "@/components/learning/DiffEditor";
import { ConceptExplorer } from "@/components/learning/ConceptExplorer";
export const mdxComponents = {
CodePlayground,
QuickCheck,
Callout,
ParameterDock,
CheckpointCard,
GlossaryPopover,
WorkedExample,
BeforeAfterSplit,
HintLadder,
DiffEditor,
ConceptExplorer,
};MDX Setup with next-mdx-remote
// app/learn/[courseSlug]/[moduleSlug]/[lessonSlug]/page.tsx
import { MDXRemote } from "next-mdx-remote/rsc";
import { mdxComponents } from "@/lib/mdx-components";
import { readFile } from "fs/promises";
import path from "path";
interface Props {
params: { courseSlug: string; moduleSlug: string; lessonSlug: string };
}
export default async function LessonPage({ params }: Props) {
const mdxPath = path.join(
process.cwd(),
"content/courses",
params.courseSlug,
params.moduleSlug,
`${params.lessonSlug}.mdx`
);
const source = await readFile(mdxPath, "utf-8");
return (
<article className="mx-auto max-w-2xl px-6 py-8">
<MDXRemote source={source} components={mdxComponents} />
</article>
);
}State Management
Three Tiers
| Tier | What | Storage | Tool |
|---|---|---|---|
| Local UI | Component interaction state (open/closed, selected tab) | React state | useState, useReducer |
| Progress | Lesson/step completion, attempts, confidence | localStorage → server | Zustand or Context |
| Exploration | Slider values, toggle states, selected views | URL search params | useSearchParams |
Progress Store (Zustand)
// lib/learning/progress-store.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface Activity {
status: "not-started" | "in-progress" | "completed";
attempts: number;
lastAttemptAt: number | null;
confidence: "low" | "medium" | "high" | null;
hintsUsed: number;
}
interface ProgressState {
activities: Record<string, Activity>; // keyed by "courseSlug/moduleSlug/lessonSlug/stepId"
getActivity: (key: string) => Activity;
updateActivity: (key: string, update: Partial<Activity>) => void;
completeActivity: (key: string, confidence?: "low" | "medium" | "high") => void;
getCourseProgress: (courseSlug: string) => number; // percentage
}
const defaultActivity: Activity = {
status: "not-started",
attempts: 0,
lastAttemptAt: null,
confidence: null,
hintsUsed: 0,
};
export const useProgressStore = create<ProgressState>()(
persist(
(set, get) => ({
activities: {},
getActivity(key) {
return get().activities[key] || defaultActivity;
},
updateActivity(key, update) {
set((state) => ({
activities: {
...state.activities,
[key]: { ...(state.activities[key] || defaultActivity), ...update },
},
}));
},
completeActivity(key, confidence) {
set((state) => ({
activities: {
...state.activities,
[key]: {
...(state.activities[key] || defaultActivity),
status: "completed",
lastAttemptAt: Date.now(),
confidence: confidence || null,
},
},
}));
},
getCourseProgress(courseSlug) {
const activities = get().activities;
const courseActivities = Object.entries(activities).filter(
([key]) => key.startsWith(courseSlug + "/")
);
if (courseActivities.length === 0) return 0;
const completed = courseActivities.filter(
([, a]) => a.status === "completed"
).length;
return Math.round((completed / courseActivities.length) * 100);
},
}),
{ name: "learning-progress" }
)
);URL State for Explorations
// See explorable-explanations.md → useExplorationState hook
// All ParameterDock, slider, and toggle values sync to URL
// Enables sharing: "Look at this specific configuration"File Organization Summary
project/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ └── learn/
│ └── [courseSlug]/[moduleSlug]/[lessonSlug]/page.tsx
├── components/
│ └── learning/
│ ├── LessonShell.tsx
│ ├── ProgressRail.tsx
│ ├── StepFlow.tsx
│ ├── CheckpointCard.tsx
│ ├── ResumeBanner.tsx
│ ├── ParameterDock.tsx
│ ├── LiveOutputPanel.tsx
│ ├── ConceptExplorer.tsx
│ ├── InteractiveDiagram.tsx
│ ├── BeforeAfterSplit.tsx
│ ├── TimelineExplorer.tsx
│ ├── ConceptMap.tsx
│ ├── CodePlayground.tsx
│ ├── DiffEditor.tsx
│ ├── TaskRunner.tsx
│ ├── SnippetCopy.tsx
│ ├── QuickCheck.tsx
│ ├── ConfidenceRating.tsx
│ ├── HintLadder.tsx
│ ├── MistakeAnalyzer.tsx
│ ├── GlossaryPopover.tsx
│ ├── Callout.tsx
│ └── WorkedExample.tsx
├── content/
│ └── courses/
│ └── <courseSlug>/
│ ├── course.json
│ └── <moduleSlug>/<lessonSlug>.mdx
├── hooks/
│ ├── useExplorationState.ts
│ └── useReducedMotion.ts
├── lib/
│ ├── mdx-components.tsx
│ └── learning/
│ ├── progress-store.ts
│ ├── grading.ts
│ └── spaced.ts
└── public/
└── fonts/
├── GeistVF.woff
└── GeistMonoVF.woffDependencies
{
"dependencies": {
"next": "^15",
"react": "^19",
"next-mdx-remote": "^5",
"@geist-ui/icons": "latest",
"zustand": "^5",
"framer-motion": "^12"
},
"devDependencies": {
"tailwindcss": "^4",
"@tailwindcss/typography": "latest"
}
}Code Learning Components
Interactive code editors, sandboxes, diff viewers, and challenge runners for teaching programming concepts.
CodePlayground
Editable code snippet with "Run" and output. Supports read-only, guided (editable blanks only), and free modes.
// components/learning/CodePlayground.tsx
"use client";
import { useState, useRef, KeyboardEvent } from "react";
import { Play, RotateCcw, Eye } from "@geist-ui/icons";
type PlaygroundMode = "read-only" | "guided" | "free";
interface CodePlaygroundProps {
initialCode: string;
language?: string;
mode?: PlaygroundMode;
editableRanges?: [number, number][]; // line ranges for "guided" mode
onRun: (code: string) => Promise<{ output: string; error?: string }>;
hints?: string[];
testResults?: { passed: boolean; label: string }[];
}
export function CodePlayground({
initialCode,
language = "typescript",
mode = "free",
editableRanges,
onRun,
hints,
testResults,
}: CodePlaygroundProps) {
const [code, setCode] = useState(initialCode);
const [output, setOutput] = useState<string>("");
const [error, setError] = useState<string>("");
const [running, setRunning] = useState(false);
const [showHints, setShowHints] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
async function handleRun() {
setRunning(true);
setError("");
try {
const result = await onRun(code);
setOutput(result.output);
if (result.error) setError(result.error);
} catch (e) {
setError(String(e));
}
setRunning(false);
}
function handleReset() {
setCode(initialCode);
setOutput("");
setError("");
}
function handleKeyDown(e: KeyboardEvent) {
// Cmd/Ctrl+Enter to run
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
handleRun();
}
// Tab to indent
if (e.key === "Tab") {
e.preventDefault();
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newCode = code.substring(0, start) + " " + code.substring(end);
setCode(newCode);
requestAnimationFrame(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2;
});
}
}
}
return (
<div className="my-6 rounded-lg border border-gray-400 overflow-hidden">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 bg-background-200 border-b border-gray-400">
<div className="flex items-center gap-2">
<span className="text-label-12-mono text-gray-400">{language}</span>
{mode !== "free" && (
<span className="rounded-full bg-blue-700/10 border border-blue-700/30 px-2 py-0.5 text-[10px] text-blue-700">
{mode === "read-only" ? "Read Only" : "Guided"}
</span>
)}
</div>
<div className="flex items-center gap-1">
{hints && hints.length > 0 && (
<button
onClick={() => setShowHints(!showHints)}
className="rounded-md p-1.5 text-gray-400 hover:text-amber-700 transition-colors"
aria-label="Show hints"
>
<Eye size={14} />
</button>
)}
<button
onClick={handleReset}
className="rounded-md p-1.5 text-gray-400 hover:text-gray-1000 transition-colors"
aria-label="Reset code"
>
<RotateCcw size={14} />
</button>
<button
onClick={handleRun}
disabled={running || mode === "read-only"}
className="flex items-center gap-1.5 rounded-md bg-white px-3 py-1 text-black text-sm font-medium hover:bg-gray-200 transition-colors disabled:opacity-30"
>
<Play size={12} />
{running ? "Running..." : "Run"}
</button>
</div>
</div>
{/* Code editor */}
<div className="relative">
<textarea
ref={textareaRef}
value={code}
onChange={(e) => mode !== "read-only" && setCode(e.target.value)}
onKeyDown={handleKeyDown}
readOnly={mode === "read-only"}
className="w-full bg-background-100 p-4 font-mono text-sm text-gray-1000 resize-none outline-none min-h-[160px] leading-relaxed"
spellCheck={false}
aria-label="Code editor"
/>
{/* Hint overlay */}
{showHints && hints && (
<div className="absolute right-4 top-4 w-64 rounded-md border border-amber-700/30 bg-amber-700/5 p-3 space-y-2">
<p className="text-label-12 text-amber-700">Hints</p>
{hints.map((hint, i) => (
<p key={i} className="text-copy-13 text-gray-400">
{i + 1}. {hint}
</p>
))}
</div>
)}
</div>
{/* Output */}
{(output || error) && (
<div className="border-t border-gray-400">
<div className="flex items-center px-4 py-1.5 bg-background-200">
<span className="text-label-12 text-gray-400">Console</span>
</div>
<div className="p-4 bg-background-100 font-mono text-sm min-h-[60px]">
{error ? (
<span className="text-red-700">{error}</span>
) : (
<span className="text-gray-1000">{output}</span>
)}
</div>
</div>
)}
{/* Test results */}
{testResults && testResults.length > 0 && (
<div className="border-t border-gray-400 px-4 py-3 bg-background-200 space-y-1.5">
<p className="text-label-12 text-gray-400">Tests</p>
{testResults.map((test, i) => (
<div key={i} className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${test.passed ? "bg-green-700" : "bg-red-700"}`} />
<span className={`text-copy-13 ${test.passed ? "text-green-700" : "text-red-700"}`}>
{test.label}
</span>
</div>
))}
</div>
)}
</div>
);
}Keyboard Shortcuts (CodePlayground)
| Key | Action |
|---|---|
Cmd/Ctrl+Enter | Run code |
Tab | Indent 2 spaces |
Shift+Tab | Dedent |
Cmd/Ctrl+Z | Undo |
DiffEditor
Before/after code diff with explanation callouts. Shows why a change fixes a bug.
// components/learning/DiffEditor.tsx
"use client";
interface DiffLine {
type: "added" | "removed" | "unchanged";
content: string;
annotation?: string; // inline explanation
}
interface DiffEditorProps {
filename?: string;
lines: DiffLine[];
title?: string;
}
export function DiffEditor({ filename, lines, title }: DiffEditorProps) {
return (
<div className="my-6 rounded-lg border border-gray-400 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-2 bg-background-200 border-b border-gray-400">
{title && <span className="text-label-14 font-medium">{title}</span>}
{filename && <span className="text-label-12-mono text-gray-400">{filename}</span>}
</div>
{/* Diff lines */}
<div className="bg-background-100 overflow-x-auto">
{lines.map((line, i) => (
<div key={i}>
<div
className={`
flex items-start px-4 py-0.5 font-mono text-sm leading-relaxed
${line.type === "added" ? "bg-green-700/5 text-green-700" : ""}
${line.type === "removed" ? "bg-red-700/5 text-red-700 line-through opacity-60" : ""}
${line.type === "unchanged" ? "text-gray-400" : ""}
`}
>
<span className="w-6 shrink-0 text-gray-400/40 select-none text-right mr-3">
{line.type === "added" ? "+" : line.type === "removed" ? "-" : " "}
</span>
<span className="flex-1 whitespace-pre">{line.content}</span>
</div>
{/* Annotation callout */}
{line.annotation && (
<div className="ml-12 mr-4 my-1 rounded-md bg-blue-700/5 border border-blue-700/20 px-3 py-1.5">
<p className="text-copy-13 text-blue-700">{line.annotation}</p>
</div>
)}
</div>
))}
</div>
</div>
);
}TaskRunner
Mini coding challenge: prompt, editor, hidden tests, visible tests, feedback with failing case. One concept, one task.
// components/learning/TaskRunner.tsx
"use client";
import { useState } from "react";
import { CodePlayground } from "./CodePlayground";
interface TestCase {
input: string;
expected: string;
label: string;
hidden?: boolean;
}
interface TaskRunnerProps {
prompt: string;
starterCode: string;
language?: string;
tests: TestCase[];
runCode: (code: string, input: string) => Promise<string>;
hints?: string[];
}
export function TaskRunner({
prompt,
starterCode,
language,
tests,
runCode,
hints,
}: TaskRunnerProps) {
const [results, setResults] = useState<{ passed: boolean; label: string; actual?: string }[]>([]);
const [allPassed, setAllPassed] = useState(false);
async function handleRun(code: string) {
const newResults: typeof results = [];
let passed = true;
for (const test of tests) {
try {
const actual = await runCode(code, test.input);
const testPassed = actual.trim() === test.expected.trim();
if (!testPassed) passed = false;
if (!test.hidden || !testPassed) {
newResults.push({
passed: testPassed,
label: test.label,
actual: testPassed ? undefined : actual,
});
}
} catch (e) {
passed = false;
newResults.push({ passed: false, label: test.label, actual: String(e) });
}
}
setResults(newResults);
setAllPassed(passed);
const output = newResults
.map((r) => `${r.passed ? "✓" : "✗"} ${r.label}${r.actual ? ` (got: ${r.actual})` : ""}`)
.join("\n");
return {
output: passed ? `All tests passed!\n\n${output}` : output,
error: passed ? undefined : "Some tests failed",
};
}
return (
<div className="my-8 space-y-4">
{/* Task prompt */}
<div className="rounded-lg border border-blue-700/30 bg-blue-700/5 p-4">
<p className="text-label-12 text-blue-700 mb-1">Challenge</p>
<p className="text-copy-14">{prompt}</p>
</div>
{/* Visible test cases */}
<div className="space-y-1">
<p className="text-label-12 text-gray-400">Test Cases</p>
{tests.filter((t) => !t.hidden).map((test, i) => (
<div key={i} className="flex items-center gap-2 text-copy-13-mono text-gray-400">
<span>{test.label}:</span>
<span className="text-gray-1000">input={test.input} → expected={test.expected}</span>
</div>
))}
{tests.some((t) => t.hidden) && (
<p className="text-copy-13 text-gray-400/60">+ {tests.filter((t) => t.hidden).length} hidden tests</p>
)}
</div>
{/* Editor */}
<CodePlayground
initialCode={starterCode}
language={language}
mode="free"
onRun={handleRun}
hints={hints}
testResults={results}
/>
{/* Success banner */}
{allPassed && (
<div className="rounded-md bg-green-700/10 border border-green-700/30 p-4" role="alert">
<p className="text-label-14 text-green-700 font-medium">All tests passed!</p>
</div>
)}
</div>
);
}SnippetCopy
Uses Geist Snippet pattern: copy button, shell prompt stripping, dark bg.
// components/learning/SnippetCopy.tsx
"use client";
import { useState } from "react";
import { Copy, Check } from "@geist-ui/icons";
interface SnippetCopyProps {
text: string;
prompt?: string; // e.g. "$" — stripped on copy
}
export function SnippetCopy({ text, prompt = "$" }: SnippetCopyProps) {
const [copied, setCopied] = useState(false);
function handleCopy() {
// Strip prompt prefix for clipboard
const clean = text.replace(new RegExp(`^\\${prompt}\\s*`, "gm"), "");
navigator.clipboard.writeText(clean);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="my-4 flex items-center justify-between rounded-lg border border-gray-400 bg-background-100 px-4 py-3">
<code className="font-mono text-sm text-gray-1000">
<span className="text-gray-400 select-none mr-2">{prompt}</span>
{text.replace(new RegExp(`^\\${prompt}\\s*`), "")}
</code>
<button
onClick={handleCopy}
className="rounded-md p-1.5 text-gray-400 hover:text-gray-1000 transition-colors shrink-0 ml-4"
aria-label="Copy to clipboard"
>
{copied ? <Check size={14} className="text-green-700" /> : <Copy size={14} />}
</button>
</div>
);
}Mode Decision Guide
| Learner Context | Mode | Why |
|---|---|---|
| First exposure to concept | read-only | Focus on reading, not editing |
| Guided practice with scaffolding | guided | Editable blanks keep focus narrow |
| Free practice / challenges | free | Full autonomy for problem-solving |
| Review / reference | read-only + copy | Quick reference, no interaction needed |
Explorable Explanations
Interactive visual components for Bret-Victor-style explorations: parameter controls, live output, diagrams, comparisons, timelines, and concept maps.
ParameterDock
A consistent control panel for sliders/toggles/selects with presets, reset, and URL state sharing.
// components/learning/ParameterDock.tsx
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { RotateCcw } from "@geist-ui/icons";
interface Param {
key: string;
label: string;
type: "slider" | "toggle" | "select";
min?: number;
max?: number;
step?: number;
options?: { value: string; label: string }[];
defaultValue: number | boolean | string;
}
interface Preset {
label: string; // e.g. "Worst Case", "Edge Case"
values: Record<string, number | boolean | string>;
}
interface ParameterDockProps {
params: Param[];
presets?: Preset[];
values: Record<string, number | boolean | string>;
onChange: (key: string, value: number | boolean | string) => void;
onReset: () => void;
}
export function ParameterDock({ params, presets, values, onChange, onReset }: ParameterDockProps) {
const router = useRouter();
const searchParams = useSearchParams();
// Sync to URL for sharing
function shareState() {
const sp = new URLSearchParams(searchParams);
for (const [k, v] of Object.entries(values)) {
sp.set(k, String(v));
}
router.replace(`?${sp.toString()}`, { scroll: false });
}
return (
<div className="rounded-lg border border-gray-400 bg-background-200 p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-label-14 font-medium">Parameters</h3>
<div className="flex items-center gap-2">
<button
onClick={shareState}
className="text-label-12 text-blue-700 hover:underline"
>
Share
</button>
<button
onClick={onReset}
className="rounded-md p-1.5 text-gray-400 hover:text-gray-1000 hover:bg-background-100 transition-colors"
aria-label="Reset parameters"
>
<RotateCcw size={14} />
</button>
</div>
</div>
{/* Presets */}
{presets && presets.length > 0 && (
<div className="flex flex-wrap gap-2">
<span className="text-label-12 text-gray-400 self-center">Try:</span>
{presets.map((preset) => (
<button
key={preset.label}
onClick={() => {
for (const [k, v] of Object.entries(preset.values)) {
onChange(k, v);
}
}}
className="rounded-full border border-gray-400 px-3 py-1 text-label-12 text-gray-400 hover:border-gray-300 hover:text-gray-1000 transition-colors"
>
{preset.label}
</button>
))}
</div>
)}
{/* Controls */}
<div className="space-y-3">
{params.map((param) => (
<div key={param.key} className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-label-12 text-gray-400" htmlFor={param.key}>
{param.label}
</label>
<span className="text-label-12-mono text-gray-1000">
{String(values[param.key])}
</span>
</div>
{param.type === "slider" && (
<input
id={param.key}
type="range"
min={param.min}
max={param.max}
step={param.step}
value={values[param.key] as number}
onChange={(e) => onChange(param.key, Number(e.target.value))}
className="w-full accent-blue-700 h-1.5"
/>
)}
{param.type === "toggle" && (
<button
id={param.key}
role="switch"
aria-checked={values[param.key] as boolean}
onClick={() => onChange(param.key, !(values[param.key] as boolean))}
className={`
relative h-5 w-9 rounded-full transition-colors
${values[param.key] ? "bg-blue-700" : "bg-gray-400"}
`}
>
<span className={`
absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform
${values[param.key] ? "translate-x-4" : "translate-x-0.5"}
`} />
</button>
)}
{param.type === "select" && (
<select
id={param.key}
value={values[param.key] as string}
onChange={(e) => onChange(param.key, e.target.value)}
className="w-full rounded-md border border-gray-400 bg-background-100 px-3 py-1.5 text-copy-14 text-gray-1000"
>
{param.options?.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
)}
</div>
))}
</div>
</div>
);
}LiveOutputPanel
Displays computed output with diff highlights, chart option, and "Explain this output" collapsible.
// components/learning/LiveOutputPanel.tsx
"use client";
import { useState, ReactNode } from "react";
import { ChevronDown, ChevronRight, Copy, Check } from "@geist-ui/icons";
interface LiveOutputPanelProps {
output: ReactNode;
previousOutput?: ReactNode; // For diff highlighting
explanation?: string;
copyValue?: string;
}
export function LiveOutputPanel({ output, previousOutput, explanation, copyValue }: LiveOutputPanelProps) {
const [showExplanation, setShowExplanation] = useState(false);
const [copied, setCopied] = useState(false);
function handleCopy() {
if (copyValue) {
navigator.clipboard.writeText(copyValue);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
return (
<div className="rounded-lg border border-gray-400 bg-background-100 overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-400">
<span className="text-label-12 text-gray-400">Output</span>
{copyValue && (
<button
onClick={handleCopy}
className="rounded-md p-1 text-gray-400 hover:text-gray-1000 transition-colors"
aria-label="Copy output"
>
{copied ? <Check size={14} className="text-green-700" /> : <Copy size={14} />}
</button>
)}
</div>
{/* Output area */}
<div className="p-4 font-mono text-sm min-h-[80px]">
{output}
</div>
{/* What changed? */}
{previousOutput && (
<div className="border-t border-gray-400 px-4 py-2">
<p className="text-label-12 text-amber-700">Changed from previous state</p>
</div>
)}
{/* Explain this output */}
{explanation && (
<div className="border-t border-gray-400">
<button
onClick={() => setShowExplanation(!showExplanation)}
className="flex w-full items-center gap-2 px-4 py-2.5 text-label-12 text-gray-400 hover:text-gray-1000 transition-colors"
>
{showExplanation ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Explain This Output
</button>
{showExplanation && (
<div className="px-4 pb-3">
<p className="text-copy-14 text-gray-400">{explanation}</p>
</div>
)}
</div>
)}
</div>
);
}ConceptExplorer
A diagram or illustration with hover/click hotspots that reveal definitions, intuition, mistakes, and examples.
// components/learning/ConceptExplorer.tsx
"use client";
import { useState, ReactNode } from "react";
interface Hotspot {
id: string;
label: string;
x: number; // percentage 0-100
y: number;
definition: string;
intuition?: string;
commonMistake?: string;
example?: ReactNode;
}
interface ConceptExplorerProps {
diagram: ReactNode; // SVG or image
hotspots: Hotspot[];
width?: number;
height?: number;
}
export function ConceptExplorer({ diagram, hotspots, width = 600, height = 400 }: ConceptExplorerProps) {
const [activeSpot, setActiveSpot] = useState<string | null>(null);
const active = hotspots.find((h) => h.id === activeSpot);
return (
<div className="my-6 rounded-lg border border-gray-400 bg-background-200 overflow-hidden">
{/* Diagram area */}
<div className="relative" style={{ maxWidth: width }}>
{diagram}
{hotspots.map((spot) => (
<button
key={spot.id}
onClick={() => setActiveSpot(activeSpot === spot.id ? null : spot.id)}
className={`
absolute h-7 w-7 -translate-x-1/2 -translate-y-1/2 rounded-full
flex items-center justify-center text-xs font-bold
transition-all duration-150
${activeSpot === spot.id
? "bg-blue-700 text-white scale-110 ring-2 ring-blue-700/30"
: "bg-background-100 border border-gray-400 text-gray-400 hover:border-blue-700 hover:text-blue-700"
}
`}
style={{ left: `${spot.x}%`, top: `${spot.y}%` }}
aria-label={spot.label}
aria-expanded={activeSpot === spot.id}
>
{hotspots.indexOf(spot) + 1}
</button>
))}
</div>
{/* Detail panel */}
{active && (
<div className="border-t border-gray-400 p-4 space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-150">
<h4 className="text-label-14 font-medium text-blue-700">{active.label}</h4>
<p className="text-copy-14">{active.definition}</p>
{active.intuition && (
<div className="rounded-md bg-background-100 p-3">
<p className="text-label-12 text-gray-400 mb-1">Intuition</p>
<p className="text-copy-14">{active.intuition}</p>
</div>
)}
{active.commonMistake && (
<div className="rounded-md bg-amber-700/5 border border-amber-700/20 p-3">
<p className="text-label-12 text-amber-700 mb-1">Common Mistake</p>
<p className="text-copy-14">{active.commonMistake}</p>
</div>
)}
{active.example && (
<div className="rounded-md bg-background-100 p-3">
<p className="text-label-12 text-gray-400 mb-1">Example</p>
{active.example}
</div>
)}
</div>
)}
</div>
);
}InteractiveDiagram
SVG/Canvas-based visual that responds to parameter inputs. Animate only transform/opacity.
// components/learning/InteractiveDiagram.tsx
"use client";
import { useReducedMotion } from "@/hooks/useReducedMotion";
import { ReactNode } from "react";
interface InteractiveDiagramProps {
width?: number;
height?: number;
children: (ctx: { animate: boolean }) => ReactNode;
}
export function InteractiveDiagram({
width = 600,
height = 300,
children,
}: InteractiveDiagramProps) {
const prefersReduced = useReducedMotion();
return (
<div className="my-6 rounded-lg border border-gray-400 bg-background-100 overflow-hidden">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-auto"
role="img"
aria-label="Interactive diagram"
>
{children({ animate: !prefersReduced })}
</svg>
</div>
);
}
// Hook for reduced motion
// hooks/useReducedMotion.ts
export function useReducedMotion(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}Diagram Animation Rules
| Rule | Implementation |
|---|---|
Only animate transform + opacity | GPU-composited, no layout thrash |
| Respect reduced motion | useReducedMotion() → instant transitions |
| Durations 150-300ms | Tight, purposeful, not decorative |
| Animate on user input only | No autoplay; feedback to interaction |
| CSS transitions preferred | Use transition prop on SVG elements |
| Framer Motion if needed | animate={{ opacity, x, y, scale }} only |
BeforeAfterSplit
Split-view with draggable divider for comparing refactors, optimizations, algorithm steps.
// components/learning/BeforeAfterSplit.tsx
"use client";
import { useState, useRef, useCallback, ReactNode } from "react";
interface BeforeAfterSplitProps {
before: ReactNode;
after: ReactNode;
beforeLabel?: string;
afterLabel?: string;
}
export function BeforeAfterSplit({
before,
after,
beforeLabel = "Before",
afterLabel = "After",
}: BeforeAfterSplitProps) {
const [split, setSplit] = useState(50);
const containerRef = useRef<HTMLDivElement>(null);
const handleDrag = useCallback((e: React.MouseEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
setSplit(Math.max(10, Math.min(90, x)));
}, []);
return (
<div className="my-6 rounded-lg border border-gray-400 overflow-hidden">
{/* Labels */}
<div className="flex border-b border-gray-400">
<span className="flex-1 px-4 py-2 text-label-12 text-gray-400 text-center">{beforeLabel}</span>
<span className="flex-1 px-4 py-2 text-label-12 text-gray-400 text-center">{afterLabel}</span>
</div>
{/* Split view */}
<div
ref={containerRef}
className="relative flex min-h-[200px] cursor-col-resize"
onMouseMove={(e) => e.buttons === 1 && handleDrag(e)}
>
<div className="overflow-hidden" style={{ width: `${split}%` }}>
<div className="p-4 bg-background-100 h-full">{before}</div>
</div>
{/* Divider */}
<div className="absolute top-0 bottom-0 w-0.5 bg-blue-700 z-10" style={{ left: `${split}%` }}>
<div className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-8 w-4 rounded-full bg-blue-700 flex items-center justify-center">
<span className="text-white text-[10px]">⇔</span>
</div>
</div>
<div className="overflow-hidden flex-1">
<div className="p-4 bg-background-200 h-full">{after}</div>
</div>
</div>
</div>
);
}TimelineExplorer
Interactive timeline with scrubber, event cards, and "compare two points" mode.
// components/learning/TimelineExplorer.tsx
"use client";
import { useState } from "react";
interface TimelineEvent {
id: string;
label: string;
description: string;
detail?: string;
}
interface TimelineExplorerProps {
events: TimelineEvent[];
initialIndex?: number;
}
export function TimelineExplorer({ events, initialIndex = 0 }: TimelineExplorerProps) {
const [index, setIndex] = useState(initialIndex);
const current = events[index];
return (
<div className="my-6 rounded-lg border border-gray-400 bg-background-200 p-4 space-y-4">
{/* Scrubber */}
<div className="space-y-2">
<input
type="range"
min={0}
max={events.length - 1}
value={index}
onChange={(e) => setIndex(Number(e.target.value))}
className="w-full accent-blue-700"
aria-label="Timeline scrubber"
/>
{/* Dots */}
<div className="flex justify-between px-1">
{events.map((evt, i) => (
<button
key={evt.id}
onClick={() => setIndex(i)}
className={`
h-2.5 w-2.5 rounded-full transition-colors
${i === index ? "bg-blue-700" : i < index ? "bg-green-700" : "bg-gray-400/40"}
`}
aria-label={evt.label}
/>
))}
</div>
</div>
{/* Event card */}
<div className="rounded-md bg-background-100 p-4 border border-gray-400">
<div className="flex items-center gap-2 mb-2">
<span className="text-label-12-mono text-blue-700">Step {index + 1}/{events.length}</span>
<h4 className="text-label-14 font-medium">{current.label}</h4>
</div>
<p className="text-copy-14 text-gray-400">{current.description}</p>
{current.detail && (
<p className="text-copy-14 mt-2">{current.detail}</p>
)}
</div>
</div>
);
}ConceptMap
Clickable nodes with labeled edges. Node click opens side panel with definition, examples, and lesson links.
// components/learning/ConceptMap.tsx
"use client";
import { useState } from "react";
interface ConceptNode {
id: string;
label: string;
x: number; // percentage
y: number;
definition: string;
examples?: string[];
lessonLink?: string;
}
interface ConceptEdge {
from: string;
to: string;
label?: string;
}
interface ConceptMapProps {
nodes: ConceptNode[];
edges: ConceptEdge[];
highlightPath?: string[]; // recommended learning path
}
export function ConceptMap({ nodes, edges, highlightPath }: ConceptMapProps) {
const [activeNode, setActiveNode] = useState<string | null>(null);
const active = nodes.find((n) => n.id === activeNode);
return (
<div className="my-6 rounded-lg border border-gray-400 bg-background-200 overflow-hidden">
<div className="relative min-h-[300px]">
{/* SVG edges */}
<svg className="absolute inset-0 w-full h-full pointer-events-none">
{edges.map((edge) => {
const from = nodes.find((n) => n.id === edge.from);
const to = nodes.find((n) => n.id === edge.to);
if (!from || !to) return null;
const isHighlighted = highlightPath?.includes(from.id) && highlightPath?.includes(to.id);
return (
<g key={`${edge.from}-${edge.to}`}>
<line
x1={`${from.x}%`} y1={`${from.y}%`}
x2={`${to.x}%`} y2={`${to.y}%`}
stroke={isHighlighted ? "#0070F3" : "#737373"}
strokeWidth={isHighlighted ? 2 : 1}
opacity={isHighlighted ? 1 : 0.4}
/>
{edge.label && (
<text
x={`${(from.x + to.x) / 2}%`}
y={`${(from.y + to.y) / 2}%`}
className="text-[10px] fill-gray-400"
textAnchor="middle"
>
{edge.label}
</text>
)}
</g>
);
})}
</svg>
{/* Nodes */}
{nodes.map((node) => (
<button
key={node.id}
onClick={() => setActiveNode(activeNode === node.id ? null : node.id)}
className={`
absolute -translate-x-1/2 -translate-y-1/2 px-3 py-1.5 rounded-md text-label-12
transition-all duration-150
${activeNode === node.id
? "bg-blue-700 text-white scale-105"
: highlightPath?.includes(node.id)
? "bg-blue-700/10 border border-blue-700/40 text-blue-700 hover:bg-blue-700/20"
: "bg-background-100 border border-gray-400 text-gray-400 hover:border-gray-300"
}
`}
style={{ left: `${node.x}%`, top: `${node.y}%` }}
>
{node.label}
</button>
))}
</div>
{/* Detail panel */}
{active && (
<div className="border-t border-gray-400 p-4 space-y-2">
<h4 className="text-label-14 font-medium">{active.label}</h4>
<p className="text-copy-14 text-gray-400">{active.definition}</p>
{active.examples && (
<ul className="list-disc list-inside text-copy-14 text-gray-400 space-y-1">
{active.examples.map((ex, i) => <li key={i}>{ex}</li>)}
</ul>
)}
{active.lessonLink && (
<a href={active.lessonLink} className="text-label-12 text-blue-700 hover:underline">
Go to lesson →
</a>
)}
</div>
)}
</div>
);
}URL State Pattern
All explorable components should support shareable state via URL search params:
// hooks/useExplorationState.ts
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { useCallback, useMemo } from "react";
export function useExplorationState<T extends Record<string, string | number | boolean>>(
defaults: T
): [T, (key: keyof T, value: T[keyof T]) => void, () => void] {
const router = useRouter();
const searchParams = useSearchParams();
const values = useMemo(() => {
const result = { ...defaults };
for (const key of Object.keys(defaults)) {
const param = searchParams.get(key);
if (param !== null) {
const defaultVal = defaults[key];
if (typeof defaultVal === "number") (result as any)[key] = Number(param);
else if (typeof defaultVal === "boolean") (result as any)[key] = param === "true";
else (result as any)[key] = param;
}
}
return result;
}, [searchParams, defaults]);
const set = useCallback((key: keyof T, value: T[keyof T]) => {
const sp = new URLSearchParams(searchParams);
sp.set(String(key), String(value));
router.replace(`?${sp.toString()}`, { scroll: false });
}, [searchParams, router]);
const reset = useCallback(() => {
router.replace("?", { scroll: false });
}, [router]);
return [values, set, reset];
}Learning Animations
Animation patterns specific to learning UIs: step transitions, feedback reveals, diagram animations, progress celebrations, and reduced-motion compliance.
Core Principles
1. Animate as feedback to user input — Never autoplay. Animation responds to interaction. 2. Animate `transform` + `opacity` only — GPU-composited, no layout thrash. 3. Respect `prefers-reduced-motion` — Instant transitions for users who prefer it. 4. 150-300ms durations — Tight, purposeful, not decorative. 5. CSS transitions first — Use Framer Motion only when CSS can't express the animation.
Reduced Motion Hook
// hooks/useReducedMotion.ts
"use client";
import { useState, useEffect } from "react";
export function useReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setPrefersReduced(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return prefersReduced;
}Step Transitions
When navigating between lesson steps in StepFlow:
// CSS-only step transition
<div
className="animate-in fade-in slide-in-from-right-4 duration-200"
key={currentStepId} // Re-mount triggers animation
>
{stepContent}
</div>With Framer Motion (if more control needed)
import { motion, AnimatePresence } from "framer-motion";
const stepVariants = {
enter: (direction: number) => ({
x: direction > 0 ? 20 : -20,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 20 : -20,
opacity: 0,
}),
};
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={currentStepId}
custom={direction}
variants={stepVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
>
{stepContent}
</motion.div>
</AnimatePresence>Reduced Motion Fallback
const reduced = useReducedMotion();
<AnimatePresence mode="wait">
<motion.div
key={currentStepId}
initial={reduced ? false : { opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={reduced ? undefined : { opacity: 0, x: -20 }}
transition={{ duration: reduced ? 0 : 0.2 }}
>
{stepContent}
</motion.div>
</AnimatePresence>Feedback Reveals
When showing correct/incorrect feedback after a quiz answer:
// Feedback panel reveal
<div
className={`
rounded-md p-4
animate-in fade-in slide-in-from-bottom-2 duration-200
${correct ? "bg-green-700/10 border border-green-700/30" : "bg-red-700/10 border border-red-700/30"}
`}
role="alert"
aria-live="polite"
>
{feedbackContent}
</div>Correct Answer Celebration (Subtle)
// A brief scale pulse on the success icon — not confetti, not fireworks
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
type: "spring",
stiffness: 400,
damping: 15,
}}
>
<CheckCircle className="text-green-700" size={24} />
</motion.div>Hint Reveals
Progressive hints slide in from below:
// Each hint animates in when revealed
<div className="animate-in fade-in slide-in-from-bottom-1 duration-200">
<div className="rounded-md bg-amber-700/5 border border-amber-700/20 px-4 py-3">
{hintContent}
</div>
</div>Diagram Animations
SVG Element Transitions
For InteractiveDiagram elements that move in response to parameters:
// SVG circle that moves when value changes
<circle
cx={computedX}
cy={computedY}
r={8}
fill="#0070F3"
style={{
transition: reduced ? "none" : "cx 200ms ease-out, cy 200ms ease-out",
}}
/>
// SVG rect that resizes
<rect
x={0}
y={barTop}
width={barWidth}
height={barHeight}
fill={highlighted ? "#46A758" : "#737373"}
rx={2}
style={{
transition: reduced ? "none" : "all 150ms ease-out",
}}
/>Array Visualization (Binary Search Example)
// Highlighting array elements during algorithm steps
{array.map((value, i) => {
const isActive = i >= low && i <= high;
const isMid = i === mid;
const isFound = i === targetIndex;
return (
<rect
key={i}
x={i * (barWidth + gap)}
y={0}
width={barWidth}
height={barHeight}
rx={2}
fill={
isFound ? "#46A758"
: isMid ? "#0070F3"
: isActive ? "#737373"
: "#737373"
}
opacity={isActive ? 1 : 0.2}
style={{
transition: reduced ? "none" : "fill 200ms, opacity 200ms",
}}
/>
);
})}Progress Animations
Progress Bar Fill
<div className="h-1.5 w-full rounded-full bg-background-200 overflow-hidden">
<div
className="h-full rounded-full bg-blue-700"
style={{
width: `${percentage}%`,
transition: "width 500ms ease-out",
}}
/>
</div>Step Completion Dot
// Dot transitions from gray to green when step completes
<span
className={`
h-2.5 w-2.5 rounded-full transition-colors duration-300
${completed ? "bg-green-700" : current ? "bg-blue-700" : "bg-gray-400/40"}
`}
/>Lesson Completion
// Subtle celebration: check icon with spring + progress bar completes
<motion.div
initial={{ scale: 0, rotate: -45 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
className="flex items-center justify-center h-12 w-12 rounded-full bg-green-700/10"
>
<CheckCircle className="text-green-700" size={28} />
</motion.div>Collapsible/Disclosure Animations
For "Why This Works", "Edge Cases", and other progressive disclosure panels:
// CSS Grid height animation (no fixed height needed)
<div
className="grid transition-[grid-template-rows] duration-200 ease-out"
style={{ gridTemplateRows: isOpen ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<div className="py-3">{content}</div>
</div>
</div>Popover/Tooltip Animations
For GlossaryPopover and ConceptExplorer hotspot panels:
// Scale-in from origin point
<div className="animate-in fade-in zoom-in-95 duration-150">
{popoverContent}
</div>
// Or with Framer Motion for exit animation
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 4 }}
transition={{ duration: 0.15 }}
>
{popoverContent}
</motion.div>
)}
</AnimatePresence>BeforeAfterSplit Divider
The draggable divider in BeforeAfterSplit should move smoothly with the cursor — no transition needed (it follows the mouse directly). But the initial appearance should fade in:
<div
className="absolute top-0 bottom-0 w-0.5 bg-blue-700 animate-in fade-in duration-300"
style={{ left: `${splitPosition}%` }}
/>Animation Duration Reference
| Animation | Duration | Easing | When |
|---|---|---|---|
| Step transition | 200ms | ease-out | Navigate between steps |
| Feedback reveal | 200ms | ease-out | Show correct/incorrect |
| Hint reveal | 200ms | ease-out | Show next hint |
| Popover open | 150ms | ease-out | Open glossary/hotspot |
| Popover close | 150ms | ease-in | Close glossary/hotspot |
| Disclosure expand | 200ms | ease-out | Open "Why This Works" |
| Diagram element move | 200ms | ease-out | Respond to parameter change |
| Progress bar fill | 500ms | ease-out | Progress update |
| Success icon | spring(400, 15) | spring | Correct answer celebration |
| Completion check | spring(300, 20) | spring | Lesson complete |
CSS Animation Utilities
Use Tailwind's built-in animation utilities where possible:
animate-in → Trigger entrance animation
fade-in → Opacity 0 → 1
slide-in-from-top-N → translateY(-Npx) → 0
slide-in-from-bottom-N → translateY(Npx) → 0
slide-in-from-left-N → translateX(-Npx) → 0
slide-in-from-right-N → translateX(Npx) → 0
zoom-in-95 → scale(0.95) → 1
duration-150 → 150ms
duration-200 → 200ms
duration-300 → 300msCombine them: animate-in fade-in slide-in-from-bottom-2 duration-200
What NOT to Animate
- Page loads — Content should be instantly visible, not staggered in
- Text appearing — No typewriter effects. Text is text.
- Scroll-triggered animations — Distraction, not pedagogy
- Looping/idle animations — Nothing moves unless the learner caused it
- Confetti/particles — We're teaching, not celebrating a purchase
- Color cycling — Colors are semantic signals, not decoration
Lesson Shell & Progress Components
The structural components that wrap every learning experience: layout, navigation, progress tracking, and resume functionality.
LessonShell
Two/three-column responsive layout. Left: lesson nav + progress. Center: content (MDX + interactive blocks). Right (optional): lab panel.
// components/learning/LessonShell.tsx
"use client";
import { ReactNode, useState } from "react";
import { ProgressRail } from "./ProgressRail";
import { Menu, X } from "@geist-ui/icons";
interface LessonShellProps {
title: string;
progress: number; // 0-100
modules: Module[];
currentLesson: string;
children: ReactNode;
labPanel?: ReactNode;
}
export function LessonShell({
title,
progress,
modules,
currentLesson,
children,
labPanel,
}: LessonShellProps) {
const [navOpen, setNavOpen] = useState(false);
return (
<div className="min-h-screen bg-background-100 text-gray-1000">
{/* Sticky top bar */}
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-gray-400 bg-background-100/80 px-4 py-3 backdrop-blur-sm">
<div className="flex items-center gap-3">
<button
onClick={() => setNavOpen(!navOpen)}
className="rounded-md p-1.5 hover:bg-background-200 transition-colors lg:hidden"
aria-label="Toggle navigation"
>
{navOpen ? <X size={18} /> : <Menu size={18} />}
</button>
<h1 className="text-label-14 font-medium truncate">{title}</h1>
</div>
<div className="flex items-center gap-4">
<span className="text-label-12-mono text-gray-400">{progress}%</span>
<div className="h-1.5 w-24 rounded-full bg-background-200">
<div
className="h-full rounded-full bg-blue-700 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</header>
<div className="flex">
{/* Left: Progress rail / nav */}
<aside
className={`
fixed inset-y-0 left-0 z-30 w-64 border-r border-gray-400 bg-background-100 pt-14
transform transition-transform duration-200
lg:relative lg:translate-x-0 lg:pt-0
${navOpen ? "translate-x-0" : "-translate-x-full"}
`}
>
<ProgressRail
modules={modules}
currentLesson={currentLesson}
/>
</aside>
{/* Center: Lesson content */}
<main className={`flex-1 px-6 py-8 ${labPanel ? "lg:max-w-3xl" : "mx-auto max-w-2xl"}`}>
{children}
</main>
{/* Right: Lab panel (optional) */}
{labPanel && (
<aside className="hidden xl:block w-[440px] border-l border-gray-400 bg-background-200 p-6 overflow-y-auto sticky top-14 h-[calc(100vh-3.5rem)]">
{labPanel}
</aside>
)}
</div>
{/* Overlay for mobile nav */}
{navOpen && (
<div
className="fixed inset-0 z-20 bg-black/50 lg:hidden"
onClick={() => setNavOpen(false)}
/>
)}
</div>
);
}ProgressRail
Shows module map, current lesson, step dots, completion status. Collapses into drawer on mobile.
// components/learning/ProgressRail.tsx
"use client";
import { CheckCircle, Circle, ArrowRight } from "@geist-ui/icons";
interface Step {
slug: string;
title: string;
status: "completed" | "current" | "pending";
}
interface Module {
slug: string;
title: string;
lessons: Step[];
}
interface ProgressRailProps {
modules: Module[];
currentLesson: string;
}
export function ProgressRail({ modules, currentLesson }: ProgressRailProps) {
return (
<nav className="p-4 space-y-6 overflow-y-auto h-full" aria-label="Course progress">
{modules.map((mod) => (
<div key={mod.slug}>
<h3 className="text-label-12 text-gray-400 uppercase tracking-wider mb-3">
{mod.title}
</h3>
<ul className="space-y-1">
{mod.lessons.map((lesson) => (
<li key={lesson.slug}>
<a
href={`#${lesson.slug}`}
className={`
flex items-center gap-2.5 rounded-md px-3 py-2 text-copy-14 transition-colors
${lesson.status === "current"
? "bg-blue-700/10 text-blue-700 font-medium"
: lesson.status === "completed"
? "text-gray-400 hover:text-gray-1000"
: "text-gray-400/60 hover:text-gray-400"
}
`}
aria-current={lesson.status === "current" ? "step" : undefined}
>
{lesson.status === "completed" ? (
<CheckCircle size={14} className="text-green-700 shrink-0" />
) : lesson.status === "current" ? (
<ArrowRight size={14} className="text-blue-700 shrink-0" />
) : (
<Circle size={14} className="text-gray-400/40 shrink-0" />
)}
<span className="truncate">{lesson.title}</span>
</a>
</li>
))}
</ul>
</div>
))}
</nav>
);
}StepFlow
Step-by-step lesson flow with gating, progress, and multiple modes.
// components/learning/StepFlow.tsx
"use client";
import { useState, ReactNode } from "react";
type StepMode = "guided" | "explore" | "review";
interface Step {
id: string;
title: string;
content: ReactNode;
check?: () => boolean; // gating function for guided mode
}
interface StepFlowProps {
steps: Step[];
mode?: StepMode;
onComplete?: () => void;
}
export function StepFlow({ steps, mode = "guided", onComplete }: StepFlowProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
const canProceed = mode === "explore" || mode === "review"
|| !steps[currentIndex]?.check
|| steps[currentIndex].check();
function handleNext() {
setCompletedSteps((prev) => new Set(prev).add(steps[currentIndex].id));
if (currentIndex < steps.length - 1) {
setCurrentIndex(currentIndex + 1);
} else {
onComplete?.();
}
}
function handleBack() {
if (currentIndex > 0) setCurrentIndex(currentIndex - 1);
}
return (
<div>
{/* Step indicators */}
<div className="flex items-center gap-2 mb-6" role="tablist" aria-label="Lesson steps">
{steps.map((step, i) => (
<button
key={step.id}
onClick={() => mode !== "guided" && setCurrentIndex(i)}
disabled={mode === "guided" && i > currentIndex}
className={`
flex items-center gap-1.5 text-label-12 transition-colors
${i === currentIndex
? "text-blue-700"
: completedSteps.has(step.id)
? "text-green-700"
: "text-gray-400/60"
}
${mode !== "guided" ? "cursor-pointer hover:text-gray-1000" : ""}
`}
role="tab"
aria-selected={i === currentIndex}
>
<span className={`
flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium
${i === currentIndex
? "bg-blue-700 text-white"
: completedSteps.has(step.id)
? "bg-green-700/20 text-green-700"
: "bg-background-200 text-gray-400"
}
`}>
{completedSteps.has(step.id) ? "✓" : i + 1}
</span>
<span className="hidden sm:inline">{step.title}</span>
</button>
))}
</div>
{/* Step content */}
<div className="min-h-[200px]" role="tabpanel">
{steps[currentIndex]?.content}
</div>
{/* Navigation */}
<div className="flex items-center justify-between mt-8 pt-4 border-t border-gray-400">
<button
onClick={handleBack}
disabled={currentIndex === 0}
className="px-4 py-2 rounded-md border border-gray-400 text-gray-400 text-sm hover:border-gray-300 transition-colors disabled:opacity-30"
>
Back
</button>
<span className="text-label-12-mono text-gray-400">
{currentIndex + 1} / {steps.length}
</span>
<button
onClick={handleNext}
disabled={!canProceed}
className="px-4 py-2 rounded-md bg-white text-black text-sm font-medium hover:bg-gray-200 transition-colors disabled:opacity-30"
>
{currentIndex === steps.length - 1 ? "Complete" : "Continue"}
</button>
</div>
</div>
);
}CheckpointCard
A "stop and do it" section that interrupts reading. Contains prompt, input UI, check, feedback.
// components/learning/CheckpointCard.tsx
"use client";
import { useState, ReactNode } from "react";
interface CheckpointCardProps {
prompt: string;
children: ReactNode; // The input/interaction UI
onCheck: () => { correct: boolean; explanation: string };
whatYouLearned?: string;
}
export function CheckpointCard({ prompt, children, onCheck, whatYouLearned }: CheckpointCardProps) {
const [result, setResult] = useState<{ correct: boolean; explanation: string } | null>(null);
function handleCheck() {
setResult(onCheck());
}
return (
<div className="my-8 rounded-lg border border-blue-700/40 bg-blue-700/5 p-6">
<div className="flex items-start gap-3 mb-4">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-700 text-white text-xs font-bold shrink-0">?</span>
<p className="text-copy-16 font-medium">{prompt}</p>
</div>
<div className="ml-9 space-y-4">
{children}
{!result && (
<button
onClick={handleCheck}
className="px-4 py-2 rounded-md bg-white text-black text-sm font-medium hover:bg-gray-200 transition-colors"
>
Check Answer
</button>
)}
{/* Feedback surface */}
{result && (
<div
className={`rounded-md p-4 ${
result.correct
? "bg-green-700/10 border border-green-700/30"
: "bg-red-700/10 border border-red-700/30"
}`}
role="alert"
aria-live="polite"
>
<p className={`text-label-14 font-medium mb-1 ${result.correct ? "text-green-700" : "text-red-700"}`}>
{result.correct ? "Correct" : "Not Quite"}
</p>
<p className="text-copy-14 text-gray-400">{result.explanation}</p>
</div>
)}
{result?.correct && whatYouLearned && (
<div className="rounded-md bg-background-200 p-4 border border-gray-400">
<p className="text-label-12 text-gray-400 mb-1">What You Learned</p>
<p className="text-copy-14">{whatYouLearned}</p>
</div>
)}
</div>
</div>
);
}ResumeBanner
Detects last completed step and offers "Resume Step 7: ..." after returning to a course.
// components/learning/ResumeBanner.tsx
"use client";
import { ArrowRight } from "@geist-ui/icons";
interface ResumeBannerProps {
lessonTitle: string;
stepNumber: number;
stepTitle: string;
onResume: () => void;
}
export function ResumeBanner({ lessonTitle, stepNumber, stepTitle, onResume }: ResumeBannerProps) {
return (
<div className="flex items-center justify-between rounded-lg border border-blue-700/30 bg-blue-700/5 px-4 py-3 mb-6">
<div>
<p className="text-label-12 text-blue-700 mb-0.5">Continue Where You Left Off</p>
<p className="text-copy-14">
<span className="text-gray-400">{lessonTitle}</span>
{" · "}
<span className="font-medium">Step {stepNumber}: {stepTitle}</span>
</p>
</div>
<button
onClick={onResume}
className="flex items-center gap-1.5 rounded-md bg-blue-700 px-3 py-1.5 text-sm text-white font-medium hover:bg-blue-600 transition-colors"
>
Resume <ArrowRight size={14} />
</button>
</div>
);
}Responsive Behavior
| Breakpoint | Nav | Content | Lab Panel |
|---|---|---|---|
| Mobile (<650px) | Drawer overlay | Full width, px-4 | Hidden (below content as tab) |
| Tablet (650-1280px) | Fixed sidebar 240px | Fluid, max-w-2xl | Hidden or drawer |
| Desktop (1280px+) | Fixed sidebar 256px | Fluid, max-w-3xl | Fixed 440px right panel |
Keyboard Navigation
| Key | Action |
|---|---|
← / → | Previous / next step (in StepFlow) |
Enter | Check answer (when checkpoint focused) |
Escape | Close mobile nav drawer |
Tab | Standard focus progression through interactive elements |
Pedagogical Framework
The learning design principles baked into every experience. These aren't suggestions — they're requirements. If the UI doesn't enforce at least one attempt+feedback cycle per chunk, it's just interactive decoration.
The Learning Loop (Central Doctrine)
Every lesson repeats this cycle. No exceptions.
Orient → Attempt → Feedback → Explain → Extend → Checkpoint → Reflect| Step | Duration | What Happens | Non-Negotiable? |
|---|---|---|---|
| Orient | 30 sec | What you'll learn, why it matters, expected time | Yes |
| Attempt | 1-3 min | Learner predicts, answers, edits, or explores | Yes |
| Feedback | Immediate | Specific response: correct/incorrect + why | Yes |
| Explain | 1-2 min | Short explanation anchored to their attempt | Yes |
| Extend | 1-2 min | Variation, edge case, or transfer task | Recommended |
| Checkpoint | 30 sec | Quick recall question | Yes |
| Reflect | 30 sec | "What changed in your understanding?" | Optional |
Minimum Viable Lesson
A lesson that skips everything but the non-negotiables:
1. One sentence stating the learning objective 2. One interactive element (QuickCheck, CodePlayground, ParameterDock) 3. Feedback on the learner's input 4. One paragraph of explanation 5. One checkpoint question
This is the floor. Every lesson must have at least this.
Progressive Disclosure Rules
Default View: One Concept + One Action
The initial visible content should show:
- What the concept is (one sentence)
- One thing the learner does with it (interact, answer, try)
Depth Affordances (collapsible)
Always available but hidden by default:
| Affordance | Reveals | Use When |
|---|---|---|
| "Why This Works" | Underlying mechanism/proof | After learner gets the basic concept |
| "Edge Cases" | Boundary conditions, exceptions | After successful first attempt |
| "Formal Definition" | Mathematical/academic phrasing | For rigorous learners |
| "Performance Notes" | Big-O, benchmarks, tradeoffs | For practical application |
| "History" | Who invented this, why | For context-hungry learners |
What NOT to Hide
- Information needed to proceed to the next step
- The current task/question
- Feedback on the learner's attempt
- Navigation (where am I, what's next)
Active Recall vs Passive Reading
QuickCheck Placement
Embed QuickChecks every 2-5 minutes of reading time (roughly every 300-600 words or every 1-2 concept sections).
| Content Length | QuickChecks |
|---|---|
| Short section (1-2 paragraphs) | 1 at end |
| Medium section (3-5 paragraphs) | 1 mid + 1 end |
| Long section (6+ paragraphs) | 1 every 2-3 paragraphs |
Question Type Selection
| Goal | Question Type | Why |
|---|---|---|
| Recall a fact | Short answer | Higher recall effort = better retention |
| Distinguish similar concepts | MCQ with misconception traps | Forces discrimination |
| Check understanding | True/False with explanation | Quick but shallow |
| Apply knowledge | CodePlayground task | Deepest learning |
| Predict behavior | "What happens if..." + reveal | Tests mental model |
After Correct Answer, Still Explain
Even when the learner gets it right, show a one-sentence confirmation:
- Reinforces the correct mental model
- Catches lucky guesses
- Adds nuance they might have missed
Spaced Repetition (Lightweight)
Not a full LMS. Simple, localStorage-based review queue.
What Gets Queued
- Glossary terms the learner "stars"
- QuickCheck questions they got wrong
- Checkpoint questions
- Items they rate "Low confidence"
Scheduling
confidence === "low" → review in 1 day
confidence === "medium" → review in 3 days
confidence === "high" → review in 7 days
On correct review:
low → medium interval (3 days)
medium → high interval (7 days)
high → 14 days
On incorrect review:
Any → reset to low interval (1 day)UI Pattern
- "Due for Review" badge on course home page
- Review page shows items as QuickCheck cards
- After review session: progress summary
Multiple Representations
Every concept should be presented in at least 2 of these 5 forms:
| Representation | Component | Best For |
|---|---|---|
| Text | Prose, Callout | Definitions, context |
| Code | CodePlayground, SnippetCopy | Implementation, syntax |
| Visual | InteractiveDiagram, ConceptExplorer | Spatial relationships, flow |
| Interactive | ParameterDock, sliders, toggles | Cause-and-effect, ranges |
| Data/Example | Table, real dataset, concrete case | Grounding abstract concepts |
Representation Switcher Pattern
Use tabs to let learners view the same concept in different forms:
<Tabs defaultValue="visual">
<Tabs.List>
<Tabs.Tab value="visual">Visual</Tabs.Tab>
<Tabs.Tab value="code">Code</Tabs.Tab>
<Tabs.Tab value="math">Math</Tabs.Tab>
<Tabs.Tab value="data">Data</Tabs.Tab>
</Tabs.List>
<Tabs.Content value="visual"><InteractiveDiagram ... /></Tabs.Content>
<Tabs.Content value="code"><CodePlayground ... /></Tabs.Content>
<Tabs.Content value="math"><p>f(x) = ...</p></Tabs.Content>
<Tabs.Content value="data"><DataTable ... /></Tabs.Content>
</Tabs>Scaffolded Complexity
The Pattern
Start with the simplest possible example. Add one dimension of complexity at a time.
| Level | What Changes | Example (Binary Search) |
|---|---|---|
| 1. Minimal | Smallest valid input | Array of 5 sorted numbers |
| 2. Scale | Larger input | Array of 100 numbers |
| 3. Edge case | Boundary conditions | Target not in array, duplicates |
| 4. Performance | Efficiency matters | Compare to linear search on 10M items |
| 5. Generalization | Abstract the pattern | Binary search on any monotonic function |
Implementation
- Each level is a step in StepFlow
- Previous level's example stays visible as reference
- New level highlights what changed (use BeforeAfterSplit)
Immediate Feedback Loops
| Interaction | Feedback Timing | Feedback Type |
|---|---|---|
| Slider drag | < 100ms | Live output update |
| Toggle flip | < 100ms | Instant visual change |
| Code run | < 2s | Console output + test results |
| Quiz answer | Instant | Green/red panel + explanation |
| Step navigation | < 200ms | Content transition |
If feedback takes > 2 seconds, show a loading skeleton (delay skeleton render by 150-300ms to avoid flash).
"Aha Moment" Patterns (Detailed)
1. Prediction Gap
1. Show setup: "Here's a function with a stale closure..."
2. Ask: "What will console.log print?"
3. Learner answers (QuickCheck short-answer)
4. Reveal actual output
5. If wrong → "Most people expect X because... but Y happens because..."
6. If right → "You got it! The key insight is..."2. Contrast Pair
1. Show two nearly identical code snippets side-by-side (BeforeAfterSplit)
2. Highlight the single difference
3. Ask: "Which one has the bug?"
4. Reveal: different output/behavior
5. Explain the subtle mechanism3. Slider to Failure
1. Working system with ParameterDock
2. Learner explores: everything works fine at defaults
3. Challenge: "Try to break it by changing the learning rate"
4. Learner drags slider until output diverges
5. "This is why X matters: beyond Y, the system Z"4. Time Travel
1. Algorithm visualization (InteractiveDiagram)
2. TimelineExplorer with step-by-step scrubber
3. Learner can go forward and backward
4. At each step: show invariants, state, and what changed
5. QuickCheck: "What will happen at step N?"5. Misconception Trap (Safe)
1. MCQ with a deliberately tempting wrong answer
2. The wrong answer matches a common misconception
3. If chosen: "Many people pick this because... but actually..."
4. MistakeAnalyzer offers specific misconception patterns
5. Corrective explanation with contrast to the right answerContent Voice for Learning
| Do | Don't |
|---|---|
| "What do you think happens?" | "As you can see..." |
| "Try changing the value" | "Observe the following" |
| "Most people expect X" | "Obviously, X" |
| "The key insight is..." | "It's trivial to show..." |
| "You got it!" | "Correct." |
| "Not quite — here's why" | "Wrong." |
| "Let's build intuition" | "The proof is left as an exercise" |
Progress & State Management
Progress tracking schema, localStorage adapter, spaced repetition scheduling, URL state for shareable explorations, and review queue.
Progress Schema
Data Model
Course → Module → Lesson → Step → ActivityEach activity is the smallest trackable unit:
// lib/learning/types.ts
interface Activity {
status: "not-started" | "in-progress" | "completed";
attempts: number;
lastAttemptAt: number | null; // Unix timestamp
confidence: "low" | "medium" | "high" | null;
hintsUsed: number;
score?: number; // Optional: 0-100
}
interface ReviewItem {
key: string; // activity key or glossary term
type: "quiz" | "glossary" | "checkpoint";
question: string;
answer: string;
confidence: "low" | "medium" | "high";
nextReviewAt: number; // Unix timestamp
interval: number; // Days until next review
reviewCount: number;
}
interface CourseProgress {
courseSlug: string;
startedAt: number;
lastAccessedAt: number;
completedModules: string[];
currentLesson: string; // "moduleSlug/lessonSlug"
currentStep: number;
}Activity Keys
Activities are keyed by path: "courseSlug/moduleSlug/lessonSlug/stepId"
binary-search/basics/the-algorithm/step-1
binary-search/basics/the-algorithm/quickcheck-midpoint
binary-search/basics/edge-cases/checkpointlocalStorage Adapter
Storage Implementation
// lib/learning/progress.ts
const STORAGE_KEY = "learning-progress";
const REVIEW_KEY = "learning-review-queue";
export function loadProgress(): Record<string, Activity> {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
export function saveProgress(activities: Record<string, Activity>): void {
if (typeof window === "undefined") return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(activities));
}
export function loadReviewQueue(): ReviewItem[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(REVIEW_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
export function saveReviewQueue(items: ReviewItem[]): void {
if (typeof window === "undefined") return;
localStorage.setItem(REVIEW_KEY, JSON.stringify(items));
}Progress Calculation
// lib/learning/progress.ts
export function calculateProgress(
activities: Record<string, Activity>,
courseSlug: string
): { percentage: number; completed: number; total: number } {
const courseActivities = Object.entries(activities).filter(
([key]) => key.startsWith(`${courseSlug}/`)
);
const total = courseActivities.length;
if (total === 0) return { percentage: 0, completed: 0, total: 0 };
const completed = courseActivities.filter(([, a]) => a.status === "completed").length;
return {
percentage: Math.round((completed / total) * 100),
completed,
total,
};
}
export function getLastPosition(
activities: Record<string, Activity>,
courseSlug: string
): { moduleSlug: string; lessonSlug: string; step: number } | null {
const courseActivities = Object.entries(activities)
.filter(([key]) => key.startsWith(`${courseSlug}/`))
.filter(([, a]) => a.status === "in-progress" || a.status === "completed")
.sort(([, a], [, b]) => (b.lastAttemptAt || 0) - (a.lastAttemptAt || 0));
if (courseActivities.length === 0) return null;
const [key] = courseActivities[0];
const parts = key.split("/");
return {
moduleSlug: parts[1],
lessonSlug: parts[2],
step: parseInt(parts[3]?.replace(/\D/g, "") || "0"),
};
}Spaced Repetition Scheduler
Scheduling Algorithm
Simple, effective, no external deps:
// lib/learning/spaced.ts
const INTERVALS: Record<string, number> = {
low: 1, // 1 day
medium: 3, // 3 days
high: 7, // 7 days
};
const PROMOTION: Record<string, string> = {
low: "medium",
medium: "high",
high: "high", // stays at high, interval doubles
};
const MS_PER_DAY = 86400000;
export function scheduleReview(
item: ReviewItem,
correct: boolean,
newConfidence: "low" | "medium" | "high"
): ReviewItem {
if (correct) {
const promoted = PROMOTION[item.confidence] as "low" | "medium" | "high";
const newInterval = item.confidence === "high"
? Math.min(item.interval * 2, 30) // Cap at 30 days
: INTERVALS[promoted];
return {
...item,
confidence: promoted,
interval: newInterval,
nextReviewAt: Date.now() + newInterval * MS_PER_DAY,
reviewCount: item.reviewCount + 1,
};
} else {
// Reset to low
return {
...item,
confidence: "low",
interval: INTERVALS.low,
nextReviewAt: Date.now() + INTERVALS.low * MS_PER_DAY,
reviewCount: item.reviewCount + 1,
};
}
}
export function getDueItems(items: ReviewItem[]): ReviewItem[] {
const now = Date.now();
return items
.filter((item) => item.nextReviewAt <= now)
.sort((a, b) => a.nextReviewAt - b.nextReviewAt);
}
export function addToReviewQueue(
items: ReviewItem[],
newItem: Omit<ReviewItem, "nextReviewAt" | "interval" | "reviewCount">
): ReviewItem[] {
// Don't add duplicates
if (items.some((i) => i.key === newItem.key)) return items;
return [
...items,
{
...newItem,
interval: INTERVALS[newItem.confidence],
nextReviewAt: Date.now() + INTERVALS[newItem.confidence] * MS_PER_DAY,
reviewCount: 0,
},
];
}Review Schedule Table
| Event | Confidence | Next Review |
|---|---|---|
| First wrong answer | Low | +1 day |
| Correct, was Low | Medium | +3 days |
| Correct, was Medium | High | +7 days |
| Correct, was High | High | +14 days (doubles, cap 30) |
| Wrong at any level | Low | +1 day (reset) |
URL State for Explorations
Shareable exploration state via URL search params. Learners can share "this specific configuration that demonstrates the edge case."
Hook
// hooks/useExplorationState.ts
// (Full implementation in explorable-explanations.md)
// Usage:
const [values, setValue, resetValues] = useExplorationState({
learningRate: 0.1,
arraySize: 10,
showDuplicates: false,
algorithm: "binary",
});
// URL becomes: ?learningRate=0.5&arraySize=100&showDuplicates=true&algorithm=linear
// Sharing this URL restores the exact exploration stateWhat Goes in URL State
| Yes (URL) | No (localStorage) |
|---|---|
| Slider values | Completion status |
| Toggle states | Number of attempts |
| Selected presets | Confidence ratings |
| Active tab/view | Hints used |
| Timeline position | Review queue |
| Diagram zoom/pan | Personal notes |
Resume Flow
How Resume Works
1. On course page load: call getLastPosition(activities, courseSlug) 2. If position found: show <ResumeBanner /> with lesson title + step 3. On click: navigate to /learn/{course}/{module}/{lesson}?step={n} 4. Lesson page reads step from URL and initializes StepFlow at that index
Implementation
// app/learn/[courseSlug]/page.tsx
import { ResumeBanner } from "@/components/learning/ResumeBanner";
import { useProgressStore } from "@/lib/learning/progress-store";
export default function CoursePage({ params }: { params: { courseSlug: string } }) {
const { activities } = useProgressStore();
const lastPosition = getLastPosition(activities, params.courseSlug);
return (
<div className="mx-auto max-w-4xl px-6 py-8">
{lastPosition && (
<ResumeBanner
lessonTitle={lastPosition.lessonSlug}
stepNumber={lastPosition.step}
stepTitle="..."
onResume={() => {
// Navigate to last position
}}
/>
)}
{/* Course overview content */}
</div>
);
}Storage Upgrade Path
| Phase | Storage | Auth Required |
|---|---|---|
| MVP | localStorage only | No |
| v2 | localStorage + optional server sync | Optional |
| v3 | Server-first with localStorage cache | Yes |
Server sync uses the same Zustand store with a sync middleware that POSTs to an API route on changes.
Quiz & Assessment Components
Active recall components: inline questions, confidence rating, progressive hints, and misconception analysis. Assessment as a learning tool, not a test.
QuickCheck
One question inline: MCQ, short answer, ordering, true/false. Instant feedback and explanation. "Try again" is the default path.
// components/learning/QuickCheck.tsx
"use client";
import { useState } from "react";
type QuestionType = "mcq" | "short-answer" | "true-false";
interface QuickCheckProps {
question: string;
type: QuestionType;
options?: string[]; // for MCQ
correctAnswer: string;
explanation: string;
misconception?: string; // Why the wrong answer seems right
onComplete?: (attempts: number, correct: boolean) => void;
}
export function QuickCheck({
question,
type,
options,
correctAnswer,
explanation,
misconception,
onComplete,
}: QuickCheckProps) {
const [answer, setAnswer] = useState("");
const [attempts, setAttempts] = useState(0);
const [result, setResult] = useState<"correct" | "incorrect" | null>(null);
const [showExplanation, setShowExplanation] = useState(false);
function handleCheck() {
const isCorrect = answer.trim().toLowerCase() === correctAnswer.trim().toLowerCase();
setAttempts((a) => a + 1);
setResult(isCorrect ? "correct" : "incorrect");
if (isCorrect) {
setShowExplanation(true);
onComplete?.(attempts + 1, true);
}
}
function handleTryAgain() {
setAnswer("");
setResult(null);
}
return (
<div className="my-6 rounded-lg border border-gray-400 bg-background-200 p-5">
<p className="text-copy-14 font-medium mb-4">{question}</p>
{/* MCQ options */}
{type === "mcq" && options && (
<div className="space-y-2 mb-4">
{options.map((opt) => (
<label
key={opt}
className={`
flex items-center gap-3 rounded-md border px-4 py-3 cursor-pointer transition-colors
${answer === opt
? result === "correct"
? "border-green-700 bg-green-700/5"
: result === "incorrect"
? "border-red-700 bg-red-700/5"
: "border-blue-700 bg-blue-700/5"
: "border-gray-400 hover:border-gray-300"
}
`}
>
<input
type="radio"
name="quickcheck"
value={opt}
checked={answer === opt}
onChange={(e) => { setAnswer(e.target.value); setResult(null); }}
className="accent-blue-700"
disabled={result === "correct"}
/>
<span className="text-copy-14">{opt}</span>
</label>
))}
</div>
)}
{/* True/False */}
{type === "true-false" && (
<div className="flex gap-3 mb-4">
{["True", "False"].map((opt) => (
<button
key={opt}
onClick={() => { setAnswer(opt); setResult(null); }}
disabled={result === "correct"}
className={`
flex-1 rounded-md border px-4 py-3 text-sm font-medium transition-colors
${answer === opt
? "border-blue-700 bg-blue-700/5 text-blue-700"
: "border-gray-400 text-gray-400 hover:border-gray-300"
}
`}
>
{opt}
</button>
))}
</div>
)}
{/* Short answer */}
{type === "short-answer" && (
<input
type="text"
value={answer}
onChange={(e) => { setAnswer(e.target.value); setResult(null); }}
disabled={result === "correct"}
placeholder="Type your answer..."
className="w-full rounded-md border border-gray-400 bg-background-100 px-4 py-2.5 text-copy-14 text-gray-1000 mb-4 outline-none focus:border-blue-700 transition-colors"
onKeyDown={(e) => e.key === "Enter" && handleCheck()}
/>
)}
{/* Actions */}
{result !== "correct" && (
<div className="flex items-center gap-3">
<button
onClick={handleCheck}
disabled={!answer}
className="px-4 py-2 rounded-md bg-white text-black text-sm font-medium hover:bg-gray-200 transition-colors disabled:opacity-30"
>
Check
</button>
{result === "incorrect" && (
<button
onClick={handleTryAgain}
className="px-4 py-2 rounded-md border border-gray-400 text-gray-400 text-sm hover:border-gray-300 transition-colors"
>
Try Again
</button>
)}
</div>
)}
{/* Feedback */}
{result && (
<div
className={`mt-4 rounded-md p-4 ${
result === "correct"
? "bg-green-700/10 border border-green-700/30"
: "bg-red-700/10 border border-red-700/30"
}`}
role="alert"
aria-live="polite"
>
<p className={`text-label-14 font-medium mb-1 ${result === "correct" ? "text-green-700" : "text-red-700"}`}>
{result === "correct" ? "Correct!" : "Not quite."}
</p>
{result === "incorrect" && misconception && (
<p className="text-copy-14 text-gray-400 mb-1">
<span className="text-amber-700">Common trap: </span>{misconception}
</p>
)}
{showExplanation && (
<p className="text-copy-14 text-gray-400">{explanation}</p>
)}
{result === "incorrect" && (
<p className="text-copy-13 text-gray-400/60 mt-2">Try again — you can do this.</p>
)}
</div>
)}
{/* Attempts counter */}
{attempts > 0 && (
<p className="text-label-12 text-gray-400/40 mt-2">
{attempts === 1 ? "1 attempt" : `${attempts} attempts`}
</p>
)}
</div>
);
}ConfidenceRating
After answering: "How confident were you?" Drives spaced repetition scheduling.
// components/learning/ConfidenceRating.tsx
"use client";
type Confidence = "low" | "medium" | "high";
interface ConfidenceRatingProps {
onRate: (confidence: Confidence) => void;
}
const levels: { value: Confidence; label: string; emoji: string; description: string }[] = [
{ value: "low", label: "Guessed", emoji: "🤔", description: "I wasn't sure — review tomorrow" },
{ value: "medium", label: "Mostly Sure", emoji: "👍", description: "I think I get it — review in 3 days" },
{ value: "high", label: "Confident", emoji: "💪", description: "I know this — review in 7 days" },
];
export function ConfidenceRating({ onRate }: ConfidenceRatingProps) {
return (
<div className="my-4 rounded-md bg-background-200 border border-gray-400 p-4">
<p className="text-label-12 text-gray-400 mb-3">How confident were you?</p>
<div className="flex gap-2">
{levels.map((level) => (
<button
key={level.value}
onClick={() => onRate(level.value)}
className="flex-1 rounded-md border border-gray-400 px-3 py-2.5 text-center hover:border-gray-300 transition-colors group"
>
<p className="text-sm font-medium group-hover:text-gray-1000 text-gray-400 transition-colors">
{level.label}
</p>
<p className="text-[11px] text-gray-400/60 mt-0.5">{level.description}</p>
</button>
))}
</div>
</div>
);
}Spaced Repetition Schedule
| Confidence | Review After | On Next Correct | On Incorrect |
|---|---|---|---|
| Low | 1 day | → Medium interval | Reset to 1 day |
| Medium | 3 days | → High interval | → Low interval |
| High | 7 days | → 14 days | → Medium interval |
HintLadder
Hints revealed progressively. Track how many hints used; reflect in progress.
// components/learning/HintLadder.tsx
"use client";
import { useState } from "react";
import { ChevronDown } from "@geist-ui/icons";
interface HintLadderProps {
hints: string[];
showAnswer?: string;
onHintUsed?: (hintIndex: number) => void;
}
export function HintLadder({ hints, showAnswer, onHintUsed }: HintLadderProps) {
const [revealedCount, setRevealedCount] = useState(0);
function revealNext() {
if (revealedCount < hints.length) {
const next = revealedCount + 1;
setRevealedCount(next);
onHintUsed?.(next - 1);
}
}
const labels = ["Nudge", "Getting Closer", "Almost There", "Full Hint"];
return (
<div className="my-4 space-y-2">
{/* Revealed hints */}
{hints.slice(0, revealedCount).map((hint, i) => (
<div
key={i}
className="rounded-md bg-amber-700/5 border border-amber-700/20 px-4 py-3 animate-in fade-in slide-in-from-bottom-1 duration-200"
>
<p className="text-label-12 text-amber-700 mb-0.5">
Hint {i + 1}: {labels[i] || `Hint ${i + 1}`}
</p>
<p className="text-copy-14">{hint}</p>
</div>
))}
{/* Show next hint button */}
{revealedCount < hints.length && (
<button
onClick={revealNext}
className="flex items-center gap-1.5 text-label-12 text-amber-700 hover:text-amber-600 transition-colors"
>
<ChevronDown size={12} />
Show Hint {revealedCount + 1} of {hints.length}
<span className="text-gray-400/40 ml-1">
({hints.length - revealedCount} remaining)
</span>
</button>
)}
{/* Show answer (after all hints or explicitly) */}
{showAnswer && revealedCount >= hints.length && (
<div className="rounded-md bg-background-200 border border-gray-400 px-4 py-3">
<p className="text-label-12 text-gray-400 mb-0.5">Answer</p>
<p className="text-copy-14">{showAnswer}</p>
</div>
)}
{/* Usage indicator */}
{revealedCount > 0 && (
<p className="text-label-12 text-gray-400/40">
{revealedCount} of {hints.length} hints used
</p>
)}
</div>
);
}MistakeAnalyzer
When wrong, offers likely misconception options to build learner metacognition.
// components/learning/MistakeAnalyzer.tsx
"use client";
import { useState } from "react";
interface Misconception {
id: string;
label: string; // e.g. "I mixed up X and Y"
explanation: string;
}
interface MistakeAnalyzerProps {
misconceptions: Misconception[];
onSelect?: (id: string) => void;
}
export function MistakeAnalyzer({ misconceptions, onSelect }: MistakeAnalyzerProps) {
const [selected, setSelected] = useState<string | null>(null);
const active = misconceptions.find((m) => m.id === selected);
return (
<div className="my-4 rounded-md border border-red-700/20 bg-red-700/5 p-4 space-y-3">
<p className="text-label-12 text-red-700">What went wrong? (It's OK — this is how learning works)</p>
<div className="space-y-2">
{misconceptions.map((m) => (
<button
key={m.id}
onClick={() => {
setSelected(m.id);
onSelect?.(m.id);
}}
className={`
w-full text-left rounded-md border px-4 py-2.5 text-copy-14 transition-colors
${selected === m.id
? "border-red-700/40 bg-red-700/10 text-gray-1000"
: "border-gray-400 text-gray-400 hover:border-gray-300"
}
`}
>
{m.label}
</button>
))}
</div>
{/* Explanation for selected misconception */}
{active && (
<div className="rounded-md bg-background-200 border border-gray-400 p-3 animate-in fade-in duration-200">
<p className="text-label-12 text-gray-400 mb-1">Here's what happened</p>
<p className="text-copy-14">{active.explanation}</p>
</div>
)}
</div>
);
}Assessment Placement Guidelines
| Context | Component | Frequency |
|---|---|---|
| Inline during reading | QuickCheck (MCQ or T/F) | Every 2-5 min of content |
| After explanation section | QuickCheck (short answer) | After key concepts |
| After quiz answer | ConfidenceRating | After every correct QuickCheck |
| When stuck on a task | HintLadder | Available on all TaskRunner/CheckpointCard |
| After incorrect answer | MistakeAnalyzer | When common misconceptions exist |
| End of lesson | Checkpoint with multiple QuickChecks | 1 per lesson |
Design Rules
- "Try Again" is always the default path — don't punish mistakes
- After correct answer, still show explanation — reinforce understanding
- Hints are a resource, not shame — count them but don't judge
- Misconception selection is voluntary — builds metacognition when used
- Green/Red feedback surfaces are consistent — same layout everywhere
- Focus moves to feedback region —
aria-live="polite"or explicit focus
Reading Support Components
Non-flashy, high-leverage components for reading comprehension: glossary popovers, callout variants, and worked examples with stepwise reveal.
GlossaryPopover
Inline term definitions on hover/click. Includes "Add to Review" action for spaced repetition.
// components/learning/GlossaryPopover.tsx
"use client";
import { useState, useRef, useEffect, ReactNode } from "react";
import { Plus } from "@geist-ui/icons";
interface GlossaryPopoverProps {
term: string;
definition: string;
children: ReactNode; // The inline text to wrap
onAddToReview?: (term: string) => void;
}
export function GlossaryPopover({ term, definition, children, onAddToReview }: GlossaryPopoverProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, [open]);
return (
<span className="relative inline" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="border-b border-dashed border-blue-700/40 text-gray-1000 hover:border-blue-700 transition-colors cursor-help"
aria-expanded={open}
aria-haspopup="dialog"
>
{children}
</button>
{open && (
<div
className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-50 w-72 rounded-lg border border-gray-400 bg-background-200 p-4 shadow-lg animate-in fade-in zoom-in-95 duration-150"
role="dialog"
aria-label={`Definition of ${term}`}
>
<div className="flex items-start justify-between gap-2 mb-2">
<h4 className="text-label-14 font-medium text-blue-700">{term}</h4>
{onAddToReview && (
<button
onClick={() => onAddToReview(term)}
className="rounded-md p-1 text-gray-400 hover:text-blue-700 transition-colors"
aria-label={`Add "${term}" to review queue`}
>
<Plus size={14} />
</button>
)}
</div>
<p className="text-copy-14 text-gray-400">{definition}</p>
{/* Arrow */}
<div className="absolute left-1/2 -translate-x-1/2 top-full w-2 h-2 rotate-45 bg-background-200 border-r border-b border-gray-400" />
</div>
)}
</span>
);
}Mobile Behavior
On mobile (< 650px), the popover becomes a bottom sheet instead of hovering above:
// Wrap popover content in a drawer for mobile
<Drawer open={open} onClose={() => setOpen(false)}>
<Drawer.Content>
<h4 className="text-label-16 font-medium mb-2">{term}</h4>
<p className="text-copy-14 text-gray-400">{definition}</p>
</Drawer.Content>
</Drawer>Callout
Geist Note/Alert variants adapted for learning context. Use sparingly — don't turn pages into a rainbow.
// components/learning/Callout.tsx
import { ReactNode } from "react";
import { Info, AlertTriangle, CheckCircle, XCircle } from "@geist-ui/icons";
type CalloutType = "key-idea" | "pitfall" | "done" | "careful";
interface CalloutProps {
type: CalloutType;
title?: string;
children: ReactNode;
}
const config: Record<CalloutType, {
icon: typeof Info;
defaultTitle: string;
border: string;
bg: string;
iconColor: string;
titleColor: string;
}> = {
"key-idea": {
icon: Info,
defaultTitle: "Key Idea",
border: "border-blue-700/30",
bg: "bg-blue-700/5",
iconColor: "text-blue-700",
titleColor: "text-blue-700",
},
"pitfall": {
icon: AlertTriangle,
defaultTitle: "Common Pitfall",
border: "border-amber-700/30",
bg: "bg-amber-700/5",
iconColor: "text-amber-700",
titleColor: "text-amber-700",
},
"done": {
icon: CheckCircle,
defaultTitle: "You're Done",
border: "border-green-700/30",
bg: "bg-green-700/5",
iconColor: "text-green-700",
titleColor: "text-green-700",
},
"careful": {
icon: XCircle,
defaultTitle: "Careful",
border: "border-red-700/30",
bg: "bg-red-700/5",
iconColor: "text-red-700",
titleColor: "text-red-700",
},
};
export function Callout({ type, title, children }: CalloutProps) {
const c = config[type];
const Icon = c.icon;
return (
<div className={`my-6 rounded-lg border ${c.border} ${c.bg} p-4`}>
<div className="flex items-start gap-3">
<Icon size={18} className={`${c.iconColor} shrink-0 mt-0.5`} />
<div>
<p className={`text-label-14 font-medium ${c.titleColor} mb-1`}>
{title || c.defaultTitle}
</p>
<div className="text-copy-14 text-gray-400 [&>p]:mb-2 [&>p:last-child]:mb-0">
{children}
</div>
</div>
</div>
</div>
);
}When to Use Each Callout
| Type | Use For | Example |
|---|---|---|
key-idea | Core concepts the learner must remember | "The key insight is that closures capture variables by reference, not value." |
pitfall | Common mistakes to avoid | "Don't use == for object comparison in Java — it compares references, not values." |
done | Congratulations / section complete | "You've completed the basics of binary search!" |
careful | Dangerous operations or critical warnings | "This operation is destructive and cannot be undone." |
Callout Frequency Rules
- Max 2 callouts per lesson section — more than that dilutes their impact
- Don't stack callouts — separate with at least 2-3 paragraphs of content
- Use `key-idea` most — it's the workhorse
- Use `careful` least — only for genuine danger
WorkedExample
Stepwise reveal of a solution. Learner can try each step first, then reveal.
// components/learning/WorkedExample.tsx
"use client";
import { useState, ReactNode } from "react";
import { ChevronRight, ChevronDown, Eye, EyeOff } from "@geist-ui/icons";
interface ExampleStep {
label: string;
content: ReactNode;
tryPrompt?: string; // e.g. "Try to figure out the next step before revealing"
}
interface WorkedExampleProps {
title: string;
steps: ExampleStep[];
revealMode?: "one-at-a-time" | "all-at-once";
}
export function WorkedExample({ title, steps, revealMode = "one-at-a-time" }: WorkedExampleProps) {
const [revealedCount, setRevealedCount] = useState(1); // Step 1 always visible
const [showAll, setShowAll] = useState(false);
const visibleCount = showAll ? steps.length : revealedCount;
function revealNext() {
if (revealMode === "all-at-once") {
setShowAll(true);
} else {
setRevealedCount((c) => Math.min(c + 1, steps.length));
}
}
return (
<div className="my-6 rounded-lg border border-gray-400 bg-background-200 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-400">
<h4 className="text-label-14 font-medium">{title}</h4>
<button
onClick={() => setShowAll(!showAll)}
className="flex items-center gap-1.5 text-label-12 text-gray-400 hover:text-gray-1000 transition-colors"
>
{showAll ? <EyeOff size={12} /> : <Eye size={12} />}
{showAll ? "Hide Steps" : "Show All"}
</button>
</div>
{/* Steps */}
<div className="divide-y divide-gray-400/40">
{steps.slice(0, visibleCount).map((step, i) => (
<div key={i} className="px-4 py-4 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="flex items-start gap-3">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-background-100 text-label-12 text-gray-400 shrink-0 border border-gray-400">
{i + 1}
</span>
<div className="flex-1">
<p className="text-label-14 font-medium mb-2">{step.label}</p>
<div className="text-copy-14 text-gray-400">{step.content}</div>
</div>
</div>
</div>
))}
{/* "Try before revealing" prompt + reveal button */}
{visibleCount < steps.length && (
<div className="px-4 py-4">
{steps[visibleCount]?.tryPrompt && (
<p className="text-copy-14 text-blue-700 mb-3">
{steps[visibleCount].tryPrompt}
</p>
)}
<button
onClick={revealNext}
className="flex items-center gap-1.5 text-label-12 text-gray-400 hover:text-gray-1000 transition-colors"
>
{revealMode === "all-at-once" ? (
<><ChevronDown size={12} /> Show All Remaining Steps</>
) : (
<><ChevronRight size={12} /> Show Step {visibleCount + 1}</>
)}
</button>
</div>
)}
{/* All done */}
{visibleCount === steps.length && (
<div className="px-4 py-3 bg-green-700/5">
<p className="text-label-12 text-green-700">Complete — all {steps.length} steps shown</p>
</div>
)}
</div>
</div>
);
}MDX Integration Pattern
All reading support components work as MDX shortcodes:
{/* In lesson .mdx file */}
A <GlossaryPopover term="closure" definition="A function that captures variables from its enclosing scope">closure</GlossaryPopover> allows inner functions to access outer variables even after the outer function returns.
<Callout type="key-idea">
Closures capture variables by **reference**, not by value. This means if the outer variable changes, the closure sees the new value.
</Callout>
<WorkedExample title="Closure Step by Step">
{[
{ label: "Define outer function", content: <code>function outer() {'{'} let x = 10; ... {'}'}</code> },
{ label: "Define inner function", content: <code>function inner() {'{'} return x; {'}'}</code>, tryPrompt: "What does inner() return if x changes?" },
{ label: "Return inner", content: <code>return inner;</code> },
]}
</WorkedExample>
<Callout type="pitfall">
In a loop, all closures share the same variable. Use `let` (block-scoped) instead of `var` to get per-iteration closures.
</Callout>Related skills
FAQ
What does geist-learning-lab do?
Build explorative, interactive learning experiences as Next.js apps using the Geist design system. Use when creating tutorials, explorable explanations, interactive lessons, code sandboxe...
When should I use geist-learning-lab?
Invoke when Build explorative, interactive learning experiences as Next.js apps using the Geist design system. Use when creating tutorials, explorable e.
Is geist-learning-lab safe to install?
Review the Security Audits panel on this page before installing in production.