code-playpad
v0.2.1
Published
Embeddable browser code playground: a CodeMirror editor that runs Python, JavaScript and TypeScript in the browser. No backend. One shared worker per page, blocking input(), multi-file projects, lazy per-language loading.
Maintainers
Readme
code-playpad
Runnable code in any web page. A CodeMirror editor that executes what it shows — Python, JavaScript and TypeScript — entirely in the reader's browser. No backend, no iframe, no sign-up.
<script type="module" src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
<code-playpad height="auto">
print("hello from a static page")
</code-playpad>Contents — Languages · Install · Attributes · Sizing · Several files · Input · Controlling it from JavaScript · Events and grading · Saving work · Light and dark · Styling · Framework recipes · Production · Worth knowing
Languages
| language | Runtime | Downloaded at runtime |
| --- | --- | --- |
| python (default) | Python 3.14 via Pyodide — full standard library, input(), real tracebacks, numpy on demand | ~11 MB, once, cached |
| javascript | The browser's own engine, in a Worker | nothing |
| typescript | Same engine, types stripped before running | ~50 KB, once, cached |
<code-playpad height="auto">print("python is the default")</code-playpad>
<code-playpad language="javascript" height="auto">
const doubled = [1, 2, 3].map((n) => n * 2);
console.log(doubled);
doubled.reduce((a, b) => a + b, 0)
</code-playpad>
<code-playpad language="typescript" height="auto">
type Point = { x: number; y: number };
const dist = (p: Point): number => Math.hypot(p.x, p.y);
dist({ x: 3, y: 4 })
</code-playpad>A trailing expression prints its value, like a REPL. Each language loads only on pages that use it: a Python-only page never downloads a byte of the JavaScript runtime, and a JavaScript-only page never touches Pyodide's 11 MB.
TypeScript here erases types, it does not check them — a type error will not
stop your code running. This is a playground, not tsc.
Install
From a CDN — nothing to install
<script type="module" src="https://cdn.jsdelivr.net/npm/[email protected]"></script>This is the standalone build: CodeMirror is bundled in, so a static blog post
or a Markdown-rendered page needs only that one tag. Pin the version — an
unpinned URL follows latest and will change under your readers.
From npm, with a bundler
npm install code-playpad @codemirror/state @codemirror/view @codemirror/commands \
@codemirror/language @codemirror/autocomplete @codemirror/theme-one-darkCodeMirror is a peer dependency so you never end up with two copies of
@codemirror/state. Language grammars are optional — install only what you
use (@codemirror/lang-python, @codemirror/lang-javascript,
@codemirror/lang-json); a missing one degrades to plain text rather than
throwing.
import 'code-playpad/element'; // registers <code-playpad>If your bundler is configured to drop side-effect-only imports, register explicitly instead:
import { defineCodePlayPad } from 'code-playpad/element';
defineCodePlayPad();Entry points
| Import | You get |
| --- | --- |
| code-playpad/element | registers <code-playpad>; smallest thing that works |
| code-playpad | the same, plus every helper below |
| code-playpad/react | the <CodePlayPad> React component |
| code-playpad/standalone | CodeMirror bundled in — what the CDN serves |
Importing on a server is safe: without a DOM the element degrades to a no-op, so
Next.js, Astro and SvelteKit need no dynamic import or client:only guard.
Attributes
| | |
| --- | --- |
| language | python (default), javascript, typescript |
| height | number (px), any CSS length, or auto |
| min-height / max-height | bounds for auto |
| theme | light, dark, or auto (default — follows the OS) |
| readonly | editor cannot be typed into; Run still works |
| autorun | runs once, on its own, when scrolled into view |
| stdin | shows the standard-input box up front |
| persist | key under which edits are saved (see Saving work) |
| entry | which file Run executes, for multi-file widgets |
<!-- a worked example nobody should edit -->
<code-playpad height="auto" readonly>
print("read this, don't change it")
</code-playpad>
<!-- output visible before the reader does anything -->
<code-playpad height="auto" autorun>
print("this already ran")
</code-playpad>
<!-- always dark, regardless of the reader's OS setting -->
<code-playpad height="auto" theme="dark">
print("always dark")
</code-playpad>readonly blocks typing but not execution, and the code can still be replaced
from JavaScript — useful for "show the answer" buttons.
Note it is language, not lang: lang is a standard HTML attribute for the
human language of an element's text, and putting python there confuses
screen readers.
Sizing
height takes a number (pixels), any CSS length, or auto to fit the
snippet:
<code-playpad height="auto">print("no dead space, no hidden lines")</code-playpad>auto is usually what you want. A fixed height has to be guessed per snippet,
and no guess is right at every viewport width — lines wrap, so a 90-character
line is one row on a desktop and three on a phone.
Two bounds go with it:
<!-- a long listing that should not push the page off screen -->
<code-playpad height="auto" max-height="400">…</code-playpad>
<!-- an answer box: one line of content, but room to type -->
<code-playpad height="auto" min-height="170"># Your code here</code-playpad>max-height scrolls rather than clips. min-height grows the editor itself, so
the empty space below a short snippet is still part of the editor and clicking
it puts the cursor there. All three take a bare number as pixels, or any CSS
length (40vh, 30em).
Several files
A widget can hold a small project. Relative imports, packages and data files all work, and tracebacks name the real file and line.
<code-playpad entry="main.py" height="auto" max-height="420">
<playpad-file name="main.py">
from geometry import Circle
print(Circle(1).area())
</playpad-file>
<playpad-file name="geometry/__init__.py">
from .shapes import Circle
</playpad-file>
<playpad-file name="geometry/shapes.py">
from math import pi
class Circle:
def __init__(self, radius): self.radius = radius
def area(self): return pi * self.radius ** 2
</playpad-file>
</code-playpad>JavaScript and TypeScript work the same way, including .json imported as data:
<code-playpad language="javascript" entry="main.js" height="auto">
<playpad-file name="main.js">
import { total } from "./cart.js";
import items from "./items.json";
console.log(total(items));
</playpad-file>
<playpad-file name="cart.js">
export const total = (items) =>
items.reduce((sum, item) => sum + item.price, 0);
</playpad-file>
<playpad-file name="items.json">
[{ "price": 12 }, { "price": 30 }]
</playpad-file>
</code-playpad>A tab bar appears once there is more than one file, with ▸ marking the entry
point, and each file keeps its own undo history. Editing a module takes effect
on the next run — the import cache is cleared between runs.
Highlighting follows the file, not the runtime: a .json beside your
JavaScript is highlighted as JSON, a .csv beside your Python is plain text.
Input
input() blocks for real. The prompt appears, a caret waits at the end of the
output, and the program continues when a line is sent — echoed the way a
terminal echoes typing:
name? Ada
age? 36
hello Ada, next year you turn 37Enter sends a line; EOF (or Ctrl-D) ends input, which is how you finish
a while True: loop.
In JavaScript and TypeScript, input() is a global returning a promise, so
await works at the top level:
<code-playpad language="javascript" height="auto">
const name = await input("your name? ");
console.log(`hello ${name}`);
</code-playpad>To script the answers instead of typing them — useful for a worked example — pre-fill the box. Those lines are used before the reader is asked:
<code-playpad height="auto" stdin>
name = input("name? ")
print("hi", name)
</code-playpad>document.querySelector('code-playpad').stdin = 'Ada\n36';Echoed input is display-only: stdout stays exactly what the program printed,
so graders are unaffected.
Controlling it from JavaScript
const pad = document.querySelector('code-playpad');
pad.code = 'print("set from outside")'; // replace the visible file
const source = pad.getCode(); // read it back
const result = await pad.run(); // { stdout, stderr?, value?, durationMs }
pad.reset(); // back to the snippet the page shipped
pad.stop(); // kill a runaway program
await pad.prewarm(); // boot the runtime before the reader runs anythingMulti-file widgets add:
pad.files = { // replace the whole project
'main.py': 'from util import shout\nprint(shout("hi"))',
'util.py': 'def shout(s): return s.upper() + "!"'
};
pad.entry = 'main.py'; // which file Run executes
pad.openFile('util.py'); // bring a tab on screen
const everything = pad.getFiles(); // { name: contents }prewarm() is worth calling when you know the reader is about to run something
— on a lesson page, for instance — so the ~11 MB Python download starts before
they press Run rather than after.
Events and grading
Four events bubble and cross the shadow boundary, so answer-checking lives outside the widget rather than inside it:
| event | event.detail |
| --- | --- |
| py-ready | { language, label } — runtime booted |
| py-run | { code, entry, files, language } — a run started |
| py-output | { stdout, stderr?, value?, table?, durationMs, language } |
| py-error | { message, hint?, language } |
A complete exercise check:
<code-playpad id="ex1" height="auto" min-height="150" persist="ex1">
# Print the numbers 1 to 5, one per line.
</code-playpad>
<p id="ex1-result"></p>const expected = '1\n2\n3\n4\n5\n';
document.addEventListener('py-output', (event) => {
if (event.target.id !== 'ex1') return;
const el = document.getElementById('ex1-result');
if (event.detail.stderr) {
el.textContent = 'Your code raised an error — read the message above.';
} else if (event.detail.stdout === expected) {
el.textContent = '✅ Correct';
} else {
el.textContent = `❌ Expected ${JSON.stringify(expected)}, got ${JSON.stringify(event.detail.stdout)}`;
}
});py-error also carries a hint when the runtime can explain the failure in
plain language — for example when a program calls input() more times than the
standard-input box has lines.
Saving work
persist stores the whole file map in IndexedDB, so a reader's edits survive
navigation and a full reload:
<code-playpad height="auto" persist="lesson-3-exercise-1">
# Your code here
</code-playpad>The page's file list stays authoritative: edits come back, but files you add or remove later follow the page, not the old snapshot.
For a course you usually also want completion state, which the package exports:
import { setProgress, getProgress, allProgress, loadSnippet, clearSnippet } from 'code-playpad';
// mark a lesson done when its exercise passes
document.addEventListener('py-output', async (event) => {
if (event.detail.stdout === expected) {
await setProgress('lesson-3', { completed: true, score: 100 });
}
});
await getProgress('lesson-3'); // { id, completed, score, updatedAt }
await allProgress(); // every record, for a progress bar
await loadSnippet('lesson-3-exercise-1'); // what the reader last wrote
await clearSnippet('lesson-3-exercise-1'); // a "start over" buttonAll of it is per-browser: nothing leaves the reader's machine.
Light and dark
theme takes light, dark, or auto (the default).
<code-playpad theme="dark" height="auto">print("always dark")</code-playpad>
<code-playpad theme="light" height="auto">print("always light")</code-playpad>
<code-playpad height="auto">print("follows the page, then the OS")</code-playpad>auto follows your site first, the operating system second. If your page
has its own light/dark switch, the widget follows it as long as the switch sets
color-scheme on the root element — which is how most themes are written:
:root { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }Flip data-theme (or a class, or an inline style) and every widget on the page
re-themes itself. Nothing else to wire up. With no color-scheme on the page,
auto falls back to the reader's OS setting via prefers-color-scheme.
If your theme switch does not set color-scheme, drive the widgets directly:
function setTheme(mode) { // mode: 'light' | 'dark'
document.documentElement.dataset.theme = mode;
for (const pad of document.querySelectorAll('code-playpad')) {
pad.setAttribute('theme', mode);
}
}An explicit theme attribute always wins over the page, so a deliberately dark
snippet stays dark on a light page.
Styling
The widget lives in a shadow root, so page CSS cannot leak in — and its own styles cannot leak out. Two supported ways to restyle it.
Custom properties inherit through the shadow boundary:
code-playpad {
--cp-accent: #b5179e; /* buttons, focus, the caret */
--cp-radius: 14px;
--cp-font-mono: "JetBrains Mono", monospace;
}| | |
| --- | --- |
| colours | --cp-bg, --cp-fg, --cp-muted, --cp-border, --cp-surface |
| accents | --cp-accent, --cp-accent-fg, --cp-danger, --cp-ok |
| shape and type | --cp-radius, --cp-font-mono, --cp-font-ui |
Parts expose the pieces worth targeting:
code-playpad::part(run-button) { border-radius: 999px; }
code-playpad::part(stop-button) { font-weight: 700; }
code-playpad::part(output) { background: #fff7fb; }
code-playpad::part(tabs) { border-bottom-width: 2px; }Both work from an ordinary stylesheet, no ::shadow hacks. For dark mode, the
theme attribute already follows the reader's OS by default.
Framework recipes
Astro — put the import in a page or layout script; the element does the rest.
---
// src/layouts/Lesson.astro
---
<script>
import 'code-playpad/element';
</script>
<code-playpad height="auto" persist={Astro.props.id}>
{Astro.props.code}
</code-playpad>React — the wrapper assigns props through a ref, so React 18 behaves like 19.
import { useState } from 'react';
import { CodePlayPad } from 'code-playpad/react';
export function Exercise() {
const [passed, setPassed] = useState(false);
return (
<>
<CodePlayPad
language="python"
code={'# Your code here'}
height="auto"
minHeight={150}
persist="exercise-1"
onOutput={(r) => setPassed(r.stdout.trim() === '42')}
/>
{passed && <p>✅ Correct</p>}
</>
);
}Props: code, files, entry, language, height, stdin, theme,
readOnly, autoRun, persist, className, style, and the callbacks
onReady, onRun, onOutput, onError.
Next.js — the element is SSR-safe, so a plain import works in a client component:
'use client';
import 'code-playpad/element';
export default function Lesson() {
return <code-playpad height="auto">{'print("hi")'}</code-playpad>;
}Vue — tell the compiler the tag is a custom element, then use it directly:
// vite.config.js
export default {
plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === 'code-playpad' } } })]
};Plain HTML / Markdown — the CDN tag plus the element. Because the code lives in the element's text content, the snippet is still readable if JavaScript never loads.
Production
Serve Pyodide from your own origin so you control its cache headers, rather than depending on a public CDN:
import { configurePython, pyodideAssetUrls } from 'code-playpad';
configurePython({ indexURL: '/pyodide/' }); // before the first run
pyodideAssetUrls(); // the files worth <link rel=preload>-ingconfigureTypeScript({ sucraseURL }) does the same for the TypeScript
transform. Give those files a long Cache-Control: one page's download then
warms every other page on the site.
Worth knowing
- One runtime per page. Every widget shares a single Worker per language, so ten editors cost one download, not ten. Runs are queued: Python is single-threaded, so a second widget waits for the first.
- Runaway code cannot freeze the page. Code runs in a Worker; Stop terminates and reboots it. Runs queued behind the stopped one survive.
- A program waiting on
input()holds the shared runtime. Other widgets showQueued…until it finishes or is stopped. - Nothing boots on page load. An IntersectionObserver starts a runtime as a
widget nears the viewport; touching the editor also wakes it. Use
prewarm()to start earlier on purpose. - Python in the browser has limits: no threads, no
multiprocessing, no sockets, nosubprocess.requestsworks but goes through browserfetch, so CORS applies. - If CodeMirror fails to load, the widget falls back to a plain textarea that still runs code, rather than showing nothing.
Version history
No breaking changes so far: markup and API written against 0.1.3 still work on 0.2.1.
0.2.1
theme="auto"now follows the page before the operating system: if your site's light/dark switch setscolor-schemeon the root, widgets re-theme with it. Previously a page could go dark while every widget stayed light.minHeightandmaxHeightprops on the React wrapper — 0.2.0 added them as attributes but missed the wrapper.- README expanded into full usage documentation: entry points, an example per attribute, programmatic control, a complete grading example, the progress store, styling, and framework recipes.
0.2.0
height="auto"is a supported content-fitting mode. It previously worked by accident and could have broken silently.- New
min-heightandmax-heightattributes.max-heightscrolls rather than clipping;min-heightgrows the editor itself, so the empty space below a short snippet is still clickable — what an exercise answer box needs.
0.1.4
- Fixed: a
<pre>or other wrapper on its own line leaked its indentation into the program, so two-line snippets failed withIndentationErrorwhile single-line ones looked fine. - Fixed:
sideEffectsnamed the re-export shims rather than the chunk that registers the element, so a bundler could legitimately drop it and<code-playpad>would never register in a production build. - No runtime dependencies (an analytics package used only by the demo had crept
into
dependencies).
0.1.3
- Relicensed MIT. Earlier versions were published as
UNLICENSED, which granted nobody the right to use them.
Versions 0.1.0 to 0.1.2 were withdrawn and are not installable.
Licence
MIT © 2026 Seung Hun Lee — see LICENSE.
The source is not published, but the package you install is MIT: use it, ship
it, modify it. Bundled third-party components (CodeMirror 6, Lezer) remain under
their own MIT licences, reproduced in full in dist/THIRD-PARTY-NOTICES.txt.
Pyodide (MPL-2.0) is fetched from a CDN at runtime and is not redistributed by
this package.
