Make your docs do things. Live code playgrounds, editable examples, quizzes, step-by-step tutorials — all built with React components inside MDX.
Static docs tell people what to do. Interactive docs let them *try it themselves*. Since MDX lets you embed React components, you can drop live code editors, quizzes, and stateful tutorials right into your writing.
Let's build some.
Live Code Playgrounds with react-live
react-live gives you an editor + live preview + error boundary in one package. It's the standard for React documentation.
Setup
Basic Playground Component
// components/Playground.jsx
import React from 'react';
import { LiveProvider, LiveEditor, LiveError, LivePreview } from 'react-live';
import { themes } from 'prism-react-renderer';
// Components available inside the playground
const scope = {
React,
useState: React.useState,
useEffect: React.useEffect,
useRef: React.useRef,
};
export function Playground({
code,
scope: additionalScope = {},
noInline = false,
language = 'jsx',
title,
description,
}) {
return (
<div className="playground my-8 border rounded-xl overflow-hidden shadow-lg">
{title && (
<div className="px-4 py-2 bg-gray-800 text-white font-medium text-sm flex items-center gap-2">
<span className="text-green-400">▶</span> {title}
</div>
)}
{description && (
<p className="px-4 py-2 bg-gray-50 dark:bg-gray-900 text-sm text-gray-600 dark:text-gray-400 border-b">
{description}
</p>
)}
<LiveProvider
code={code.trim()}
scope={{ ...scope, ...additionalScope }}
theme={themes.nightOwl}
noInline={noInline}
language={language}
>
<div className="grid grid-cols-1 lg:grid-cols-2">
{/* Editor Panel */}
<div className="relative">
<div className="absolute top-2 right-2 text-xs text-gray-400 z-10">
EDITABLE
</div>
<LiveEditor
className="font-mono text-sm !bg-[#011627] min-h-[200px]"
style={{ fontFamily: 'JetBrains Mono, monospace' }}
/>
</div>
{/* Preview Panel */}
<div className="border-l border-gray-200 dark:border-gray-700">
<div className="px-3 py-1 bg-gray-100 dark:bg-gray-800 text-xs text-gray-500 border-b">
PREVIEW
</div>
<div className="p-4 min-h-[200px] bg-white dark:bg-gray-950">
<LivePreview />
</div>
</div>
</div>
{/* Error Display */}
<LiveError className="px-4 py-2 bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 text-sm font-mono border-t border-red-200" />
</LiveProvider>
</div>
);
}
Using Playground in MDX
Here's how you'd drop it into an MDX file:
import { Playground } from '../components/Playground';
# Buttons
Learn how to create interactive buttons with React.
<Playground
title="Interactive Button Example"
description="Try changing the button text, color, or adding onClick behavior."
code={`
function App() {
const [count, setCount] = React.useState(0);
return (
<div style={{ textAlign: 'center' }}>
<h3>Click Counter</h3>
<button
onClick={() => setCount(c => c + 1)}
style={{
padding: '12px 24px',
fontSize: '16px',
backgroundColor: count > 5 ? '#ef4444' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
Clicked {count} times
</button>
{count > 5 && <p style={{ color: '#ef4444' }}>🔥 You're on fire!</p>}
</div>
);
}
`}
/>
Advanced: Multi-File Playground
Want users to switch between files like a real editor? Here's how:
// components/MultiFilePlayground.jsx
import React, { useState } from 'react';
import { LiveProvider, LiveEditor, LiveError, LivePreview } from 'react-live';
import { themes } from 'prism-react-renderer';
export function MultiFilePlayground({ files, scope = {} }) {
const [activeFile, setActiveFile] = useState(Object.keys(files)[0]);
const [editedFiles, setEditedFiles] = useState(files);
// Combine all files into executable code
const combinedCode = Object.values(editedFiles).join('\n\n');
return (
<div className="multi-playground border rounded-xl overflow-hidden my-8">
{/* File Tabs */}
<div className="flex bg-gray-900 border-b border-gray-700">
{Object.keys(files).map(filename => (
<button
key={filename}
onClick={() => setActiveFile(filename)}
className={`px-4 py-2 text-sm font-mono transition-colors ${
activeFile === filename
? 'bg-gray-800 text-white border-b-2 border-blue-400'
: 'text-gray-400 hover:text-gray-200'
}`}
>
{filename}
</button>
))}
</div>
<LiveProvider code={combinedCode} scope={scope} noInline theme={themes.nightOwl}>
<div className="grid grid-cols-2">
<div>
<LiveEditor
key={activeFile}
className="font-mono text-sm min-h-[300px]"
onChange={(newCode) => {
setEditedFiles(prev => ({ ...prev, [activeFile]: newCode }));
}}
/>
</div>
<div className="border-l">
<div className="p-4 bg-white min-h-[300px]">
<LivePreview />
</div>
</div>
</div>
<LiveError className="p-3 bg-red-50 text-red-600 text-sm font-mono" />
</LiveProvider>
</div>
);
}
Interactive Examples
Prop Explorer
This one's great for component libraries. Users tweak props with sliders and dropdowns, and see the component update in real time:
// components/PropExplorer.jsx
import React, { useState } from 'react';
export function PropExplorer({ component: Component, defaultProps, propTypes }) {
const [props, setProps] = useState(defaultProps);
return (
<div className="prop-explorer border rounded-xl overflow-hidden my-8">
{/* Preview */}
<div className="p-8 bg-gray-50 dark:bg-gray-900 border-b flex items-center justify-center min-h-[150px]">
<Component {...props} />
</div>
{/* Controls */}
<div className="p-4 space-y-4">
<h4 className="font-semibold text-sm uppercase text-gray-500">Props</h4>
{Object.entries(propTypes).map(([propName, config]) => (
<div key={propName} className="flex items-center gap-4">
<label className="w-32 text-sm font-mono text-gray-700">{propName}</label>
{config.type === 'string' && (
<input
type="text"
value={props[propName] || ''}
onChange={e => setProps(p => ({ ...p, [propName]: e.target.value }))}
className="flex-1 px-3 py-1 border rounded text-sm"
/>
)}
{config.type === 'boolean' && (
<input
type="checkbox"
checked={props[propName] || false}
onChange={e => setProps(p => ({ ...p, [propName]: e.target.checked }))}
className="w-4 h-4"
/>
)}
{config.type === 'select' && (
<select
value={props[propName]}
onChange={e => setProps(p => ({ ...p, [propName]: e.target.value }))}
className="px-3 py-1 border rounded text-sm"
>
{config.options.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
)}
{config.type === 'number' && (
<input
type="range"
min={config.min || 0}
max={config.max || 100}
value={props[propName] || 0}
onChange={e => setProps(p => ({ ...p, [propName]: Number(e.target.value) }))}
className="flex-1"
/>
)}
</div>
))}
</div>
{/* Generated Code */}
<div className="px-4 py-3 bg-gray-900 text-green-400 font-mono text-sm">
<code>{`<Component ${Object.entries(props)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => typeof v === 'boolean' ? (v ? k : '') : `${k}="${v}"`)
.filter(Boolean)
.join(' ')} />`}</code>
</div>
</div>
);
}
And using it in MDX:
import { PropExplorer } from '../components/PropExplorer';
import { Button } from '../components/Button';
## Button Component
Explore the Button component props interactively:
<PropExplorer
component={Button}
defaultProps={{ children: 'Click me', variant: 'primary', size: 'md', disabled: false }}
propTypes={{
children: { type: 'string' },
variant: { type: 'select', options: ['primary', 'secondary', 'outline', 'ghost'] },
size: { type: 'select', options: ['sm', 'md', 'lg', 'xl'] },
disabled: { type: 'boolean' },
}}
/>
Editable Code Blocks
Turn static code blocks into ones users can edit and run:
// components/EditableCode.jsx
import React, { useState, useCallback } from 'react';
export function EditableCode({ initialCode, language = 'javascript', validator, onSuccess }) {
const [code, setCode] = useState(initialCode);
const [output, setOutput] = useState(null);
const [error, setError] = useState(null);
const runCode = useCallback(() => {
try {
setError(null);
// Create a safe evaluation context
const logs = [];
const mockConsole = {
log: (...args) => logs.push(args.map(String).join(' ')),
error: (...args) => logs.push(`ERROR: ${args.map(String).join(' ')}`),
};
const fn = new Function('console', code);
fn(mockConsole);
setOutput(logs.join('\n'));
// Validate if a validator is provided
if (validator) {
const isValid = validator(code, logs);
if (isValid && onSuccess) onSuccess();
}
} catch (err) {
setError(err.message);
setOutput(null);
}
}, [code, validator, onSuccess]);
return (
<div className="editable-code border rounded-xl overflow-hidden my-6">
<div className="flex items-center justify-between px-4 py-2 bg-gray-800 text-white">
<span className="text-sm font-mono">{language}</span>
<button
onClick={runCode}
className="px-3 py-1 bg-green-600 hover:bg-green-700 rounded text-sm font-medium transition-colors"
>
▶ Run
</button>
</div>
<textarea
value={code}
onChange={e => setCode(e.target.value)}
className="w-full p-4 font-mono text-sm bg-gray-900 text-gray-100 resize-y min-h-[150px] focus:outline-none"
spellCheck={false}
/>
{output !== null && (
<div className="p-3 bg-gray-100 dark:bg-gray-800 border-t font-mono text-sm">
<div className="text-xs text-gray-500 mb-1">Output:</div>
<pre className="text-green-700 dark:text-green-400 whitespace-pre-wrap">{output}</pre>
</div>
)}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950 border-t font-mono text-sm text-red-600">
<div className="text-xs text-red-400 mb-1">Error:</div>
{error}
</div>
)}
</div>
);
}
Quiz Components
Drop a quiz into your docs to test whether readers actually understood what they just read:
// components/Quiz.jsx
import React, { useState } from 'react';
export function Quiz({ question, options, correct, explanation }) {
const [selected, setSelected] = useState(null);
const [submitted, setSubmitted] = useState(false);
const isCorrect = selected === correct;
return (
<div className="quiz border rounded-xl p-6 my-8 bg-gray-50 dark:bg-gray-900">
<div className="flex items-center gap-2 mb-4">
<span className="text-xl">🧠</span>
<h4 className="font-semibold text-lg">Knowledge Check</h4>
</div>
<p className="text-gray-800 dark:text-gray-200 mb-4 font-medium">{question}</p>
<div className="space-y-2 mb-4">
{options.map((option, index) => (
<label
key={index}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
submitted && index === correct
? 'border-green-500 bg-green-50 dark:bg-green-950'
: submitted && index === selected && !isCorrect
? 'border-red-500 bg-red-50 dark:bg-red-950'
: selected === index
? 'border-blue-500 bg-blue-50 dark:bg-blue-950'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="radio"
name="quiz"
checked={selected === index}
onChange={() => !submitted && setSelected(index)}
disabled={submitted}
className="w-4 h-4"
/>
<span>{option}</span>
{submitted && index === correct && <span className="ml-auto">✅</span>}
{submitted && index === selected && !isCorrect && <span className="ml-auto">❌</span>}
</label>
))}
</div>
{!submitted ? (
<button
onClick={() => selected !== null && setSubmitted(true)}
disabled={selected === null}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700 transition-colors"
>
Check Answer
</button>
) : (
<div className={`p-4 rounded-lg ${isCorrect ? 'bg-green-100 dark:bg-green-900' : 'bg-yellow-100 dark:bg-yellow-900'}`}>
<p className="font-medium mb-1">
{isCorrect ? '🎉 Correct!' : '💡 Not quite...'}
</p>
<p className="text-sm text-gray-700 dark:text-gray-300">{explanation}</p>
</div>
)}
</div>
);
}
Here's what it looks like in use:
import { Quiz } from '../components/Quiz';
<Quiz
question="In MDX, what does the 'export default' statement define for the document?"
options={[
"The document's title",
"The layout component that wraps the content",
"The list of available components",
"The document's metadata"
]}
correct={1}
explanation="The default export in MDX defines the layout component. The MDX content is passed as 'children' to this component, allowing you to wrap content in consistent page structures."
/>
Step-by-Step Tutorials with State
Guide users through a multi-step process with progress tracking:
// components/Tutorial.jsx
import React, { useState } from 'react';
export function Tutorial({ steps }) {
const [currentStep, setCurrentStep] = useState(0);
const [completedSteps, setCompletedSteps] = useState(new Set());
const step = steps[currentStep];
const progress = (completedSteps.size / steps.length) * 100;
const markComplete = () => {
setCompletedSteps(prev => new Set([...prev, currentStep]));
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1);
}
};
return (
<div className="tutorial border rounded-xl overflow-hidden my-8">
{/* Progress Bar */}
<div className="h-2 bg-gray-200">
<div
className="h-full bg-blue-500 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
{/* Step Navigation */}
<div className="flex border-b overflow-x-auto">
{steps.map((s, i) => (
<button
key={i}
onClick={() => setCurrentStep(i)}
className={`px-4 py-3 text-sm whitespace-nowrap transition-colors ${
i === currentStep
? 'border-b-2 border-blue-500 text-blue-600 font-medium'
: completedSteps.has(i)
? 'text-green-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{completedSteps.has(i) ? '✅ ' : `${i + 1}. `}
{s.title}
</button>
))}
</div>
{/* Step Content */}
<div className="p-6">
<h3 className="text-xl font-bold mb-2">{step.title}</h3>
<p className="text-gray-600 mb-4">{step.description}</p>
{step.code && (
<pre className="p-4 bg-gray-900 text-gray-100 rounded-lg text-sm overflow-x-auto mb-4">
<code>{step.code}</code>
</pre>
)}
{step.hint && (
<details className="mb-4 p-3 bg-yellow-50 dark:bg-yellow-950 rounded-lg">
<summary className="cursor-pointer text-sm font-medium text-yellow-800 dark:text-yellow-200">
💡 Need a hint?
</summary>
<p className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">{step.hint}</p>
</details>
)}
<div className="flex items-center justify-between mt-6">
<button
onClick={() => setCurrentStep(Math.max(0, currentStep - 1))}
disabled={currentStep === 0}
className="px-4 py-2 border rounded-lg disabled:opacity-50 hover:bg-gray-50 transition-colors"
>
← Previous
</button>
<span className="text-sm text-gray-500">
Step {currentStep + 1} of {steps.length}
</span>
<button
onClick={markComplete}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{currentStep === steps.length - 1 ? '✓ Complete' : 'Next →'}
</button>
</div>
</div>
</div>
);
}
import { Tutorial } from '../components/Tutorial';
<Tutorial steps={[
{
title: "Create your MDX file",
description: "Create a new file with the .mdx extension in your pages directory.",
code: "touch pages/my-first-post.mdx",
hint: "MDX files can live anywhere your bundler is configured to look for them."
},
{
title: "Add frontmatter",
description: "Add YAML frontmatter at the top of your file for metadata.",
code: "---\ntitle: \"My First Post\"\ndate: \"2024-01-15\"\n---",
hint: "Frontmatter must be the very first thing in the file, with no blank lines above it."
},
{
title: "Write your content",
description: "Mix Markdown with JSX components for rich interactive content.",
code: "# Hello World\n\nThis is **Markdown** with <MyComponent prop=\"value\" />",
hint: "You can import components at the top of your MDX file just like in a JS module."
},
{
title: "Import and use components",
description: "Import React components and use them alongside your prose.",
code: "import { Chart } from '../components/Chart'\n\n# Data Report\n\n<Chart data={[1, 2, 3, 4, 5]} type=\"line\" />",
hint: "Components are interactive by default — state, effects, and event handlers all work."
}
]} />
Sandbox Integration
For full app sandboxes, embed CodeSandbox or StackBlitz directly:
// components/Sandbox.jsx
export function Sandbox({ id, provider = 'codesandbox', title, height = 500 }) {
const urls = {
codesandbox: `https://codesandbox.io/embed/${id}?fontsize=14&hidenavigation=1&theme=dark&view=split`,
stackblitz: `https://stackblitz.com/edit/${id}?embed=1&file=src/App.tsx&theme=dark`,
};
return (
<div className="sandbox my-8 rounded-xl overflow-hidden border shadow-lg">
{title && (
<div className="px-4 py-2 bg-gray-800 text-white text-sm font-medium">
{title}
</div>
)}
<iframe
src={urls[provider]}
style={{ width: '100%', height: `${height}px`, border: 0 }}
title={title || 'Code Sandbox'}
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
loading="lazy"
/>
</div>
);
}
import { Sandbox } from '../components/Sandbox';
## Full Example
Explore the complete working application:
<Sandbox
id="mdx-interactive-demo-abc123"
provider="codesandbox"
title="MDX Interactive Components Demo"
height={600}
/>
These components add value, but they also add bytes. Here's what to expect:
| Component | Bundle Impact | Optimization |
|---|
| react-live | ~45KB gzipped | Lazy load, only on pages with playgrounds |
| CodeSandbox embed | 0KB (iframe) | Use loading="lazy" |
| Quiz component | ~2KB | Minimal, always include |
| Tutorial component | ~3KB | Minimal, always include |
| Prop Explorer | ~5KB | Lazy load for component docs |
Lazy load the heavy stuff:
// Lazy load playground for performance
import React, { lazy, Suspense } from 'react';
const Playground = lazy(() => import('./Playground'));
export function LazyPlayground(props) {
return (
<Suspense fallback={
<div className="h-[400px] border rounded-xl flex items-center justify-center bg-gray-50">
<span className="text-gray-500">Loading playground...</span>
</div>
}>
<Playground {...props} />
</Suspense>
);
}
Tip: Use code splitting so playground code only loads on pages that need it. On a docs site, this can shave 50KB+ off non-interactive pages.
Interactive docs turn passive readers into active learners. When people can edit code, answer questions, and follow guided steps right in the page, they actually remember what they read.