Code playgrounds let users experiment with code in the browser. Here is how to build one with live preview.
Install Dependencies
pnpm add @monaco-editor/react
File System Types
// types.ts
export interface PlaygroundFile {
name: string;
language: string;
content: string;
}
export interface ConsoleMessage {
type: "log" | "error" | "warn" | "info";
args: string[];
timestamp: number;
}
Monaco Editor Wrapper
"use client";
import Editor from "@monaco-editor/react";
interface CodeEditorProps {
value: string;
language: string;
onChange: (value: string) => void;
readOnly?: boolean;
}
export function CodeEditor({
value,
language,
onChange,
readOnly = false,
}: CodeEditorProps) {
return (
<Editor
height="100%"
language={language}
value={value}
onChange={(val) => onChange(val ?? "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: "on",
folding: true,
wordWrap: "on",
scrollBeyondLastLine: false,
readOnly,
tabSize: 2,
automaticLayout: true,
padding: { top: 12 },
}}
/>
);
}
Sandboxed Preview
"use client";
import { useRef, useEffect, useCallback, useState } from "react";
import type { PlaygroundFile, ConsoleMessage } from "./types";
interface PreviewProps {
files: PlaygroundFile[];
onConsoleMessage: (msg: ConsoleMessage) => void;
}
export function Preview({ files, onConsoleMessage }: PreviewProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [error, setError] = useState<string | null>(null);
const buildHtml = useCallback((files: PlaygroundFile[]) => {
const html = files.find((f) => f.name.endsWith(".html"))?.content ?? "";
const css = files.find((f) => f.name.endsWith(".css"))?.content ?? "";
const js = files.find((f) => f.name.endsWith(".js"))?.content ?? "";
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>${css}</style>
</head>
<body>
${html}
<script>
// Capture console output
const originalConsole = { ...console };
['log', 'error', 'warn', 'info'].forEach(method => {
console[method] = (...args) => {
originalConsole[method](...args);
parent.postMessage({
type: 'console',
method,
args: args.map(a => {
try { return typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a); }
catch { return String(a); }
}),
timestamp: Date.now()
}, '*');
};
});
// Capture errors
window.onerror = (msg, source, line, col, error) => {
parent.postMessage({
type: 'error',
message: String(msg),
line,
}, '*');
};
try {
${js}
} catch (e) {
console.error(e.message);
}
</script>
</body>
</html>`;
}, []);
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (event.data.type === "console") {
onConsoleMessage({
type: event.data.method,
args: event.data.args,
timestamp: event.data.timestamp,
});
}
if (event.data.type === "error") {
setError(`Line ${event.data.line}: ${event.data.message}`);
}
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [onConsoleMessage]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
setError(null);
const doc = iframe.contentDocument;
if (!doc) return;
const html = buildHtml(files);
doc.open();
doc.write(html);
doc.close();
}, [files, buildHtml]);
return (
<div className="relative h-full bg-white">
{error && (
<div className="absolute top-0 left-0 right-0 bg-red-50 border-b border-red-200 px-3 py-1.5 text-xs text-red-700 z-10">
{error}
</div>
)}
<iframe
ref={iframeRef}
sandbox="allow-scripts"
className="w-full h-full border-0"
title="Preview"
/>
</div>
);
}
Console Panel
"use client";
import type { ConsoleMessage } from "./types";
interface ConsoleProps {
messages: ConsoleMessage[];
onClear: () => void;
}
const typeColors: Record<string, string> = {
log: "text-foreground",
error: "text-red-500",
warn: "text-amber-500",
info: "text-blue-500",
};
export function ConsolePanel({ messages, onClear }: ConsoleProps) {
return (
<div className="h-full flex flex-col bg-muted/30">
<div className="flex items-center justify-between px-3 py-1.5 border-b">
<span className="text-xs font-medium">Console</span>
<button
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear
</button>
</div>
<div className="flex-1 overflow-y-auto font-mono text-xs p-2 space-y-0.5">
{messages.length === 0 && (
<span className="text-muted-foreground">No output yet</span>
)}
{messages.map((msg, i) => (
<div key={i} className={`${typeColors[msg.type]} py-0.5`}>
<span className="opacity-50 mr-2">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
{msg.args.join(" ")}
</div>
))}
</div>
</div>
);
}
Full Playground Component
"use client";
import { useState, useCallback, useRef } from "react";
import { CodeEditor } from "./CodeEditor";
import { Preview } from "./Preview";
import { ConsolePanel } from "./ConsolePanel";
import type { PlaygroundFile, ConsoleMessage } from "./types";
const defaultFiles: PlaygroundFile[] = [
{
name: "index.html",
language: "html",
content: '<div id="app">\n <h1>Hello World</h1>\n <button id="btn">Click me</button>\n <p id="count">Count: 0</p>\n</div>',
},
{
name: "styles.css",
language: "css",
content:
"body {\n font-family: system-ui, sans-serif;\n padding: 2rem;\n background: #f9fafb;\n}\n\nh1 { color: #111827; }\n\nbutton {\n padding: 0.5rem 1rem;\n background: #3b82f6;\n color: white;\n border: none;\n border-radius: 0.375rem;\n cursor: pointer;\n}\n\nbutton:hover { background: #2563eb; }",
},
{
name: "script.js",
language: "javascript",
content:
'let count = 0;\nconst btn = document.getElementById("btn");\nconst display = document.getElementById("count");\n\nbtn.addEventListener("click", () => {\n count++;\n display.textContent = `Count: ${count}`;\n console.log("Count:", count);\n});',
},
];
export function Playground() {
const [files, setFiles] = useState<PlaygroundFile[]>(defaultFiles);
const [activeFile, setActiveFile] = useState(0);
const [consoleMessages, setConsoleMessages] = useState<ConsoleMessage[]>([]);
const [showConsole, setShowConsole] = useState(true);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Debounced file content update
const [previewFiles, setPreviewFiles] = useState(files);
const updateFile = useCallback(
(content: string) => {
setFiles((prev) => {
const next = [...prev];
next[activeFile] = { ...next[activeFile], content };
return next;
});
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setFiles((current) => {
setPreviewFiles(current);
return current;
});
}, 500);
},
[activeFile],
);
const handleConsoleMessage = useCallback((msg: ConsoleMessage) => {
setConsoleMessages((prev) => [...prev.slice(-200), msg]);
}, []);
return (
<div className="h-[600px] flex flex-col border rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-1 px-2 py-1 bg-muted/50 border-b">
{files.map((file, i) => (
<button
key={file.name}
onClick={() => setActiveFile(i)}
className={`px-3 py-1 text-xs rounded-md ${
i === activeFile
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{file.name}
</button>
))}
<div className="flex-1" />
<button
onClick={() => {
setConsoleMessages([]);
setPreviewFiles([...files]);
}}
className="px-2 py-1 text-xs bg-primary text-primary-foreground rounded-md"
>
Run
</button>
<button
onClick={() => setShowConsole((s) => !s)}
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
>
Console
</button>
</div>
{/* Editor and Preview */}
<div className="flex-1 flex min-h-0">
<div className="w-1/2 border-r">
<CodeEditor
value={files[activeFile].content}
language={files[activeFile].language}
onChange={updateFile}
/>
</div>
<div className="w-1/2 flex flex-col">
<div className={showConsole ? "flex-1" : "h-full"}>
<Preview
files={previewFiles}
onConsoleMessage={handleConsoleMessage}
/>
</div>
{showConsole && (
<div className="h-40 border-t">
<ConsolePanel
messages={consoleMessages}
onClear={() => setConsoleMessages([])}
/>
</div>
)}
</div>
</div>
</div>
);
}
Need Interactive Documentation?
We build code playgrounds and interactive docs for developer tools. Contact us to discuss your project.