<code-preview>
A code block that renders itself: <code-preview> wraps a highlighted
<pre><code> in a live preview — an iframe above the code, the code editable on
request, edits applied as you type. The sample is the only source of truth, so a documented
example and what it actually does cannot drift apart.
Edit any sample below — the preview above it follows as you type. The theme switcher
in the topbar drives this page and, wherever a sample passes
theme-attribute="data-theme", the frame with it.
On this page
Features
Live-code components are not a new idea. What is specific here:
- It wraps the code block you already have. Every other tool in this space asks you to
author demos in its own format — a js function, a multi-file manifest, a custom fence.
This takes the
<pre><code>your site generator already emitted, hljs classes and all, and upgrades it in place. Nothing to port. - Emulated viewport widths.
viewport-widthgives the frame a genuine css width and scales the result down to fit, so a sample's desktop media queries actually apply inside a 700px docs column.viewport-widthsturns that into a row of buttons. - It lives in the light DOM. The code block keeps this page's syntax theme and prose
styles instead of being sealed off from them by a shadow root, and the page's
[data-theme]is mirrored into the frame — so a demo goes dark with the docs around it. A tool that sandboxes its preview onto a separate origin structurally cannot do that. - Scenery behind the sample.
backdroplays a<template>from your page under every preview that names it — column guides, a baseline ruler, a device bezel. Written once in the layout, and never a tab, never highlighted, never editable: the code block still shows only the sample. - Several fences, several tabs. Markup, css and js written as the three blocks they are become three panes, the language read off each fence. Nothing to configure.
- An options panel from a manifest you already ship. The controls are generated from
custom-elements.json— the format the ecosystem has, not one invented here. - A console strip under the block. What the sample logs, and an uncaught throw, land against the code that caused them rather than in a devtools panel.
- Editing is asked for, not assumed. A block is a code block until you press Edit; a page full of quietly editable blocks is a keyboard trap in the middle of the prose.
- Two script tags and no build step. 7.5KB gzipped on a docs site that already ships highlight.js — CodeJar for the editor is 2KB of that — 23KB standalone, with highlight.js carried along. No service worker, no bundler config, no origin to serve demo files from.
Intended use
Documentation for something you can demonstrate in markup — a css library, a custom
element, anything whose api is an attribute and a class name. The sample is the
<pre><code> your site generator already emitted, and the preview above it is that exact
text rendered, so the example on the page and the thing it documents cannot drift apart.
Everything below this section is that arrangement, on the page it documents.
It assumes the code is yours. The frame is a srcdoc document with no sandbox, which is
what buys the height measurement and the theme write — it is also same-origin, so a sample
can reach the page around it. That is the right trade for prose you wrote plus edits a
reader types into their own browser: the worst case is a reader XSSing themselves, and
nothing is stored or shared. It is the wrong one for a playground whose samples arrive in
a url, which is somebody else's script running on your origin.
And demo code is never built — it goes into the frame verbatim. No TypeScript, no JSX, no
bare imports resolved from npm, no fork-and-keep. When the sample needs any of those,
the tool for it is playground-elements,
Sandpack or a StackBlitz embed; the
readme has the whole table
of when to reach for which.
A sample with no assets
Renders in a bare document, so it shows the browser's own defaults. Every sample on
this page also carries its own --code-preview-height, measured once — the stylesheet's
8rem default is a guess, and six previews all settling away from it after load is a page
that moves under an anchor link.
<p>Hello from inside the frame.</p>
<p><small>Try editing this.</small></p>
A sample with a stylesheet
css takes whitespace-separated urls, resolved against this page.
<div class="card">
<h3>Card</h3>
<p>Styled by sample.css, which follows the page's theme.</p>
</div>
A wide viewport, scaled down
A frame in a text column is a ~700px viewport, and the media queries inside read that
honestly — so a responsive sample only ever shows its narrow layout.
viewport-width="1024" renders at a real 1024px and scales the result to fit, so the
wider breakpoints apply. viewport-widths adds the buttons — or narrow your window and
watch it fall back on its own.
<div class="cols">
<div class="card">
<h3>One</h3>
<p>Three across above 900px.</p>
</div>
<div class="card">
<h3>Two</h3>
<p>Two across above 600px.</p>
</div>
<div class="card">
<h3>Three</h3>
<p>Stacked below that.</p>
</div>
</div>
Scenery behind the sample
Some samples need a set, not just a stage. backdrop names a <template> on this page by
id, and its markup goes into the frame underneath the sample — column guides here, a
baseline ruler or a device bezel elsewhere. It is scenery and not sample: it is not a
fence, so it is not a tab, is not highlighted and cannot be typed into, and the code block
below still shows only the three cards. Write the template once in your layout and every
preview on the page opts in with one attribute.
The guides are built from the same breakpoints .cols uses, inside the same frame, so
they answer the same width — press the buttons and watch the columns land on them.
<div class="cols">
<div class="card">
<h3>One</h3>
<p>Three across above 900px.</p>
</div>
<div class="card">
<h3>Two</h3>
<p>Two across above 600px.</p>
</div>
<div class="card">
<h3>Three</h3>
<p>The guides move with them.</p>
</div>
</div>
A sample that runs its own script
Markup and css are inert, so they apply as you type. Js is not, and it is the one thing
this element will not do behind your back: half-typed js in a same-origin frame hangs
this page along with the preview. An inline <script> is js wherever it was typed, so
this sample waits. Change something below and press Run in the block's bottom-left
corner, or Ctrl/Cmd + Enter without leaving the editor.
Run means run, every time — press it without editing anything and the counter starts
over. And what the sample logs lands in a console strip under the block it is logged from,
which starts over with it — an uncaught throw included, tinted and announced.
<button id="go">Click me</button>
<p id="out"></p>
<script>
let n = 0;
go.onclick = () => {
out.textContent = `clicked ${++n}`;
console.log("clicked", n);
};
</script>
Several fences, several tabs
Markup, css and js written as the three fences they are become three tabs — the
language read off each fence, nothing to configure. The tabs sit above the code they
switch; Edit and Run sit in the block's bottom-left corner, opposite the theme's own copy
button, and Edit means whichever pane you are looking at — an open editor follows you
across the tabs. Only the js pane waits for Run, the way an inline <script> does:
a css edit is a write into the frame's stylesheet, and a markup edit re-runs the js
pane's own complete text, never the half-typed line under your cursor. Click the button
a few times, then restyle it on the CSS tab and watch the count survive.
<button id="go">Click me</button>
<p id="out"></p>
button {
padding: 0.4rem 1rem;
}
let n = 0;
go.onclick = () => (out.textContent = `clicked ${++n}`);
Read-only
no-edit renders the preview and leaves the code alone.
<p>You cannot type in this one.</p>
Give it panes to name and it locks only those: no-edit="css js" keeps the markup
editable and turns its stylesheet and script into what they are here — context to read,
not knobs to turn. Name a pane by what its tab says (html, css, js) or by the tab's
own name (code for the markup one); the tabs you leave out stay editable.
A fence can also say it itself, which is usually the shorter thing to write, and it does
not need a new markdown vocabulary — a bare word after the language is already a class on
the block, and no-edit is the class this reads:
```css no-edit
.pill {
border-radius: 999px;
}
```
In hand-written markup it is an attribute instead, on the block or on the <pre> around
it: <pre no-edit><code class="language-css">…</code></pre>.
Both at once, below: the css pane locks itself from its fence, the js pane is locked by name on the element.
<button class="pill">Type in me</button>
.pill {
border: 0;
border-radius: 999px;
padding: 0.4rem 1rem;
}
document.querySelector(".pill").onclick = (event) =>
(event.target.textContent = "Read-only css");
An options panel
manifest points at a
custom-elements.json, and
its presence is what turns the second tab on. The controls are generated from it: the
attributes come from attributes[], the custom properties from cssProperties[], and
each one's control from the type or syntax the manifest already declares. No manifest,
no tabs — every page above renders exactly as it did.
The two halves write to two different places, and both are honest. An attribute belongs to an element in the sample, so its knob rewrites the code above and the code tab keeps telling the truth — edit it back by hand and the panel re-reads it. A custom property is not part of the sample at all: it goes into a stylesheet inside the frame, exactly where you would put it, and the rule is printed at the bottom of the panel for you to copy. Turn nothing, and nothing is written.
Events are the third group, and the only read-only one: everything in events[] is
listed whether or not it has fired, and counted as it does. Click the badge in the
preview. The color swatch beside --demo-badge-outline is the other thing worth looking
at — its default is transparent, which no color picker can hold, so it is drawn crossed
out rather than shown as black.
<p>Shipping status: <demo-badge label="New" count="3"></demo-badge></p>
Pointed at itself
The manifest above is hand-written, because the element it documents is: demo-badge is
a tag with no JavaScript behind it, so there is no JSDoc for a generator to read. This
package's own manifest is not hand-written. dist/custom-elements.json is produced in the
build by
@custom-elements-manifest/analyzer
from the JSDoc block on the element class, and shipped under the package's
customElements key — the same route this page asks of everyone else. Which means the
element can be handed itself and has to cope.
The sample below is a <code-preview>, and a real one: the frame loads the same bundles
and the same stylesheet this page does, so what is in there is the element rather than a
picture of one. It links code-preview-hljs.min.css too, which this page does not — the
frame is exactly the case that optional stylesheet exists for, a page with no syntax theme
of its own. self.css is the other half of the same point: it names --bg,
--fg, --border and the rest of this theme's tokens, at this theme's own values, copied
out of its stylesheet — and the element inside picks every one it reads up without being told, because
that is what its custom properties fall back to. Which is why the block in there looks like
the block around it. The last few lines of that file are the part the tokens cannot do: the
element leaves the <pre> to its host, so the frame also needs the theme's code-block rule,
or a themed preview would sit above an unstyled slab of text.
Open the Options tab and every knob is this element's own documented surface, the two
halves behaving as they did above and for the same reasons. --code-preview-accent and
--code-preview-radius restyle the element in the frame and leave the one you are reading
alone: a custom property is written into a stylesheet inside the frame, and the frame is
where it stops. no-console is an attribute, so it is written into the code instead, onto
the <code-preview> in the sample — tick it and the strip under the inner block stops
taking the button's console.log; untick it and the next click is heard again. manifest
is the one to try last. Point it at custom-elements.json and the element in the frame
grows an Options tab of its own, because the options bundle is loaded in there as well.
The button is three documents down: this page, the frame it built, and the frame the element in that built.
<code-preview style="--code-preview-height: 56px">
<pre><code class="language-html"><button onclick="console.log('Three documents down.')">
Log something
</button></code></pre>
</code-preview>
In another language
Twelve strings go in front of a reader — the two buttons, the keyboard hint in both of its
states, and the accessible names on the tabs, the width switcher, the console and the block
itself. window.codePreviewStrings replaces the ones a page cares about.
The rule is that it has to be set before the element registers, and it is the one place
this differs from the highlighter hook next door. A highlighter can be read per call because
recoloring happens again on every keystroke; a label is written once, when the block is
built, and a block already in the markup is built the instant the bundle calls define.
There is no moment after that script tag left to say what language the page is in.
So the demo is nested, the way the self-preview above is: the frame below loads
strings-sr.js first in its js list and the element bundle behind it.
Every url there is deferred and deferred scripts run in document order, which is what makes
"first in the list" mean "before define" — the same guarantee an ordinary page gets from
writing one <script> tag above another.
Open the inner block and the hint, the buttons and the tab all speak Serbian. The console
strip does not: strings-sr.js leaves console out, and what you leave out keeps its
English default rather than going blank. Click the button to see it.
<code-preview viewport-widths="320" style="--code-preview-height: 56px">
<pre><code class="language-html"><button onclick="console.log('Pozdrav!')">
Pozdravi
</button></code></pre>
</code-preview>
Markup still outranks all of it. A <pre> or <code> carrying its own aria-label keeps
it — a docs page that has already named a sample knows more about it than either default.
Questions
Is there a build step?
No. A stylesheet and a script, and any <pre><code> you wrap is live — this page is two
<script> tags and a <link>. A block your generator already highlighted is left exactly
as it is, so nothing reshuffles on load.
Why an iframe, and not the markup inlined into the page?
Because a docs page cannot host a sample of a css library safely: tag-level rules for
html, body or * restyle the docs around it, @layer base rules lose to the theme,
and scoping the stylesheet under a wrapper selector takes :root with it and kills the
custom properties. The frame is the isolation — and for a css library it is also the
honest demo, a real page loading the real stylesheet.
Why does a js edit wait for Run when css applies as I type?
Because no delay makes running half-typed code safe. The frame is same-origin, so it
shares this page's event loop: while (true with the closing paren still to come hangs
the whole tab, not just the preview. A longer debounce would not prevent that, it would
only decide how long you get first. Markup and css are inert, so they have no such line to
cross. Ctrl/Cmd + Enter and closing the editor are the
other two ways to apply a js edit.
Can I use Prism, Shiki or my own highlighter?
Yes. The default build asks the page's window.hljs for one method, so anything of that
shape stands in, as long as it is in place before the element registers:
window.hljs = {
highlightElement(element) {
/* recolor it, leave textContent alone */
},
};
The block arrives carrying class="hljs language-<lang>" and the sample is its
textContent — a highlighter that rewrites that text breaks the preview it feeds.
What does a reader with no JavaScript see?
The code block, highlighted, exactly as the page shipped it. The preview is an
enhancement over markup that stands on its own, which is the other half of why the sample
is a <pre><code> rather than a string in a config file.
Why does every sample here carry its own --code-preview-height?
--code-preview-height?A preview's real height is its sample's, and nothing knows that until the frame has
rendered — so space is reserved before either happens, and the stylesheet's 8rem default
is a guess. Measured once per sample, it is not a guess: on this page that is the
difference between a CLS of 0.0285 and 0.0022. no-shrink covers the other direction,
a late re-measure that comes back shorter.
Can it preview a React or TypeScript component?
No — demo code goes into the frame verbatim, so there is no compile step to turn JSX or
types into something a browser runs, and no resolver for a bare import. A component
already built to a bundle you can point js at is a different question, and that one
works: it is markup and a script, which is all this element ever handles. See
Intended use.