npm GitHub

<code-preview>

npm version ci license MIT

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:

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">&lt;button onclick="console.log('Three documents down.')"&gt;
  Log something
&lt;/button&gt;</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">&lt;button onclick="console.log('Pozdrav!')"&gt;
  Pozdravi
&lt;/button&gt;</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?

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.