Skip to main content
Back to Blog
Tutorials
4 min read
January 13, 2025

How to Build a Code Playground and Sandbox in React

Create an interactive code playground with Monaco editor, live preview, multi-file support, and sandboxed execution using iframes.

Ryel Banfield

Founder & Lead Developer

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.

code playgroundsandboxMonacoReacteditortutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles