Skip to content

Repository files navigation

🪟 code-preview-element 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.

Live demo → — the element, demonstrated by the element.

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, and the page is a plain code block if the script never loads.
  • Emulated viewport widths. viewport-width gives 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-widths turns that into a row of buttons.
  • It lives in the light DOM. The code block keeps the host page's syntax theme and prose styles instead of being sealed off from them by a shadow root, and the host 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 second part.
  • Scenery behind the sample. backdrop lays 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, generated from your JSDoc, 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, 23KB standalone — see Three builds. 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.

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. Reach for something else when the shape of the problem is that instead:

Instead When
playground-elements multi-file samples, TypeScript compiled in the browser, bare imports resolved from npm. Costs a service worker and a few hundred KB.
Sandpack demoing React components rather than markup and CSS.
@mdjs/mdjs-preview you are already on the mdjs/rocket toolchain and write demos as JS functions.
StackBlitz / CodePen embeds the reader should be able to fork the sample and keep it.
CodeMirror / Monaco the code block should be a real editor — multi-cursor, linting, IntelliSense. See Editing is asked for for what that costs a docs page.

Install

npm install code-preview-element

A stylesheet and a script, and any <pre><code> you wrap is live:

<link
  rel="stylesheet"
  href="node_modules/code-preview-element/dist/code-preview.min.css"
/>
<script src="node_modules/code-preview-element/dist/code-preview.min.js"></script>

<code-preview css="dist/my-library.css">
  <pre><code class="language-html">&lt;button class="btn"&gt;Hi&lt;/button&gt;</code></pre>
</code-preview>

That build is 7.5KB gzipped and brings no highlighter, because it expects the page to already have one — a docs site loading a second copy of highlight.js is 15KB spent on nothing. If yours has none, swap in dist/code-preview-hljs.min.js, which carries its own. See Three builds.

Or skip the install and take the same two files from a CDN:

<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/code-preview-element@3/dist/code-preview.min.css"
/>
<script src="https://cdn.jsdelivr.net/npm/code-preview-element@3/dist/code-preview.min.js"></script>

From a bundler, import for the side effect — every entry defines the element itself, so there is nothing to call:

import "code-preview-element"; // default build; uses window.hljs if the page has one
import "code-preview-element/hljs"; // the same element, highlight.js bundled in
import "code-preview-element/options"; // the options panel, on top of either
import "code-preview-element/style"; // required css
import "code-preview-element/theme"; // optional highlight.js token colors

Import one of the first two, not both. The next two are stylesheets, so they need a bundler that accepts a css import; dist/code-preview.css and dist/code-preview-hljs.css are the same files for anything that would rather link them.

The element's own manifest is a fifth export, for a tool that reads one — or for pointing this element at itself:

import manifest from "code-preview-element/custom-elements.json" with { type: "json" };

It is also the package's customElements key, which is where an editor looks without being told.

How it works

The sample can arrive plain, as above — the element highlights it on upgrade. A block that is already highlighted (a fence a site generator ran hljs over at build time) is left exactly as it is: re-running hljs would be work for an identical result, and any version skew between build and runtime shows up as the whole block reshuffling on load.

An iframe, rather than 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.

Attributes

Attribute Effect
css whitespace-separated stylesheet urls for the frame
js whitespace-separated script urls for the frame
head extra head html, replacing the default body{margin:0;padding:1rem}
backdrop id of a <template> on the page, laid under the sample as scenery
theme-attribute attribute the host page's [data-theme] is mirrored onto, inside the frame
viewport-width render at this css width and scale it down to fit
viewport-widths whitespace-separated widths to offer as buttons
manifest url of a custom-elements.json — its presence turns the options panel on
manifest-tag which declaration in it to drive. Default: the first declared tag the sample uses
tab which pane is open, and the live state: code (default), css, js, options
no-edit leave the code read-only — all of it, or only the panes it names
no-actions drop the Edit and Run buttons — both, or only the one it names
no-toast no event name over the preview — the panel still counts what fires
no-console no console strip under the code, and no console hook in the frame — errors still show
no-shrink never size the preview below its tallest measurement
reload always rebuild the frame on edit, never patch it

Relative urls in css/js resolve against the host page — that is what a srcdoc document inherits as its base url — so a page two directories down needs ../../dist/my-library.css, exactly as it would in its own markup.

head replaces the frame's default head, which is nothing but <style>body{margin:0;padding:1rem}</style> — the padding that keeps a sample off the frame's edge. Replacing means replacing, so re-state it if you still want it:

<code-preview
  head="&lt;style&gt;body{margin:0;padding:2rem;font-family:system-ui}&lt;/style&gt;"
>
  <pre><code class="language-html">&lt;p&gt;Roomier, and not Times New Roman.&lt;/p&gt;</code></pre>
</code-preview>

It is the escape hatch for what css and js cannot say — a <meta>, a font <link>, an import map. It lands after the css links and before the js scripts, so a rule here outranks one from a stylesheet at equal specificity. A sample that is already a whole <html> document is used verbatim, and all three attributes are ignored.

Scenery behind the sample

Some samples need a set, not just a stage: column guides behind a layout demo, a baseline ruler behind type, a device bezel. backdrop names a <template> on the host page by id, and its markup is laid in the frame's body underneath every sample this element renders — before it in the document, so the sample stacks over it.

It is scenery, not sample. It is not a fence, so it is not a tab, is not highlighted and cannot be typed into; the code block keeps showing only what the page is documenting. Write the template once — in the layout, in a partial — and every preview on the page opts in with one attribute:

<template id="grid-guides">
  <div class="grid-guides" aria-hidden="true">
    <div class="container grid grid-gutter">
      <div class="col-3 col-lg-1"><i></i></div>
      <!-- …eleven more… -->
    </div>
  </div>
</template>

<code-preview css="../../dist/my-library.css" backdrop="grid-guides" viewport-widths="480 768 1024">
  <pre><code class="language-html">&lt;div class="container grid grid-gutter"&gt;…&lt;/div&gt;</code></pre>
</code-preview>

Because the guides are built from the library's own classes inside the frame, they answer the frame's width — so they re-flow with the sample as the viewport-widths buttons change it, and the reader watches the layout land on the columns at each breakpoint.

Two rules for what the template draws. It has to be out of flowposition: fixed with inset: 0, which is what a full-bleed guide wants anyway — or it measures as content and the preview grows by its height. And it should be aria-hidden="true": a dozen empty divs are decoration, and a screen reader reading the scenery before every sample is worse than no scenery at all.

Styling it is the host page's business, through css: the template is inert markup that the host page never renders, so it takes its look from the stylesheets already going into the frame. A sample that is already a whole <html> document gets no backdrop, for the same reason it gets no css, js or head — it owns its document.

Responsive samples

A preview frame in a text column is a ~700px viewport, and media queries inside it read that width honestly — so a responsive sample only ever demonstrates its narrow layout. viewport-width fixes that by giving the frame a real width and scaling the rendered result down to fit:

<code-preview css="dist/my-library.css" viewport-width="1024"></code-preview>

CSS zoom is not an alternative — it shrinks the rendering without changing the viewport the media queries are asked about. This does change it: the frame genuinely is 1024px wide, then transform: scale() makes it fit, and a ResizeObserver recomputes the factor when the column resizes. A container already that wide is left alone rather than scaled up.

Keep the emulated width modest — 1024 rather than 1600. Everything in the frame shrinks by the same factor, text included, and a preview nobody can read is not a better preview.

To let a reader compare breakpoints instead of picking one for them, add viewport-widths:

<code-preview
  css="dist/my-library.css"
  viewport-widths="375 768 1024"
></code-preview>

That renders a row of buttons — Fit, then one per width — which do nothing but set viewport-width on the element. The attribute stays the single source of truth, so a hand-written attribute, a click and a script all take the same path, and combining the two attributes just decides which button starts out pressed. Fit removes the attribute: natural width, no scaling.

Wiring it up

The element reads the code out of its own child <pre><code>, so anything that produces a highlighted code block can feed it. Three shapes that has taken:

By hand, as above — fine for a handful of samples.

From a build step, for docs generated from markdown. Have the generator wrap already-highlighted fences after the markup stage, which is also where the per-page url prefix is known. A marker in the markdown keeps it opt-in:

// <!-- demo --> followed by an html fence, in the built html
const marker =
  /<!-- demo -->\s*(<pre><code class="hljs language-html">[\s\S]*?<\/code><\/pre>)/g;
html = html.replace(
  marker,
  (_, fence) =>
    `<!-- demo --><code-preview css="${prefix}dist/my-library.css">${fence}</code-preview>`,
);

Leave the marker in the output and put the element between it and the fence: the pattern then no longer matches, so a re-run is a no-op — which matters for watch modes that recompile only the pages that changed and then post-process all of them.

From highlight.js itself, if the page highlights at runtime — docsify, a hand-written page, anything without a markup build to hook into. hljs takes plugins, and after:highlightElement fires once per block with the <code> in hand:

hljs.addPlugin({
  "after:highlightElement": ({ el }) => {
    // Opt in per block, and never wrap twice. Whichever signal your generator can
    // put on a fence works — a class here, because it survives the most of them.
    if (!el.classList.contains("demo") || el.closest("code-preview")) return;

    const pre = el.parentElement;
    const preview = document.createElement("code-preview");
    preview.setAttribute("css", "/dist/my-library.css");

    // Assembled off-DOM and inserted whole. The element upgrades the instant it is
    // connected and looks for its `<pre><code>` right then, so a wrapper connected
    // empty and filled afterwards never builds a preview at all.
    const anchor = document.createComment("code-preview");
    pre.replaceWith(anchor);
    preview.appendChild(pre);
    anchor.replaceWith(preview);
  },
});
hljs.highlightAll();

Register the plugin before highlightAll; the element's own script tag can come in either order. Two things are easier here than in a build step: the css url is resolved by the browser, so one absolute path serves every page at any depth, and the opt-in check is a predicate rather than a regex over html — data- attributes, the language, the sample's own content, whatever the block already carries.

Opting in is the whole difficulty, and it is a markdown problem rather than an hljs one: a docs page is full of html fences that are not demos, so every html fence is the wrong default. What gets a class onto a fence depends on the generator — markdown-it-attrs takes ```html {.demo}, several others pass the info string through as extra classes, and a plugin-less setup has the marker-comment route above.

Editing

The editor is CodeJar, which is in both bundles — it is 2KB, and it is the reason the block can be typed into at all. Recoloring on every keystroke means replacing the block's innerHTML, which drops the caret and shreds the undo stack; restoring both through IME composition and Firefox's contenteditable quirks is why that library exists. A bare contenteditable would not have been less code here, only worse.

highlight.js is pinned to v11 to match what static site generators emit at build time. If runtime and build-time output disagree, the block visibly reshuffles the first time it is focused.

Editing is asked for, not assumed

A block that can be edited is not editable until you say so. At rest it is a code block: no contenteditable, nothing announced as a text field, and a pencil button in its bottom-left corner saying it could be. Press that — or press Enter with the block focused — and it becomes an editor. Esc closes it again, as does pressing the button a second time.

The mode follows you across tabs: you are editing the sample, not one block, so switching to the css pane opens its editor in the same gesture. A pane with no editor — the options panel, a read-only fence — closes it, the way it always did.

An always-editable block is the wrong default, and the reason is Tab. Tab has to indent inside a code editor, so it cannot also be the way out; a block that is quietly editable is therefore a keyboard trap sitting in the middle of a docs page, and every reader tabbing through hits it without having asked for an editor at all. The way out of that trap is a key nobody has been told about, told to them only once they are already in it.

Opting in fixes both ends. Nobody lands in an editor they did not ask for, because the block is not focusable until it is one; and the Esc advice is owed only to someone who deliberately opened it, so it can appear at the moment it becomes true rather than as a permanent caption. What the block does keep at rest is a tab stop — that is what Enter opens, and it means a keyboard user is offered the editor where they already are instead of having to go and find an icon.

Closing the editor is also the second way to apply a js edit, alongside the Run button: a sample that changed re-runs on the way out.

The Esc hint is not drawn on a touch device — (hover: none) and (pointer: coarse). It names a key that is not there, over the corner of the sample a thumb is about to land in, and the trap it warns about is a keyboard trap that a device with no keyboard cannot be in. The way out on touch is the Edit button, filled in for as long as there is something to get out of. The aria-describedby stays either way, since a screen reader is a keyboard whatever the pointer is doing.

The badge this replaces — an Editable pill, and the --code-preview-edit-label property that reworded it — is gone. A button says the same thing and can be pressed.

Markup and css apply as you type; js waits for Run

Markup and css are inert, so they follow the typing on a 250ms debounce. Patching keeps stylesheets loaded and the scroll position, and an edit to the css pane is cheaper still — the pane is one <style> in a head this element built, so it is a write to that element's text. Nothing reloads and nothing reparses, which means the sample keeps everything a rebuild would cost it: a script's variables, an open menu, the control that had focus.

The text that is code is the exception, and it waits for the Run button, for Ctrl/Cmd + Enter in the editor, or for the editor being closed. Run is always live and always re-runs the sample from whatever the blocks say when it is pressed — it is not an "apply the edit" button, so pressing it on a sample nobody has touched still starts the demo over: the counter back to zero, the animation from the top. Two edits count: the js pane's own text, and an inline <script> written in the markup pane — that one is js wherever it was typed, and a single-fence js demo is exactly that.

A button rather than a longer debounce, because no delay makes running half-typed code safe. A srcdoc frame is same-origin, so it shares the page's event loop: while (true with the closing paren still to come hangs the whole tab, not just the preview. A longer debounce does not prevent that, it only decides how long the reader gets first.

A sample that runs js it is not typing — a js asset on the element, the reload attribute — still follows the typing: the rebuild re-runs that js from its own file, complete and valid, never mid-statement. What the rebuild costs is the sample's live state, which is why markup edits patch wherever nothing ran at all.

A sample that is a whole document is rebuilt rather than patched — render cannot patch a head it did not write — but that is not the same question. With no script in it there is nothing to execute, so it keeps the live typing markup gets.

The console

What the sample logs appears under the code block, in a strip that exists only once something has been logged: console.log, info, warn, error and debug all land there, still reaching the browser's own console too. It sits against the block rather than in a tab of its own — where a devtools console sits relative to the source above it, and on screen while you type the js that causes the lines. It holds the last hundred, follows the tail unless you have scrolled up to read, and starts over when the frame rebuilds — a new document is a new run, the same bargain the event counts make. It is a role="log" region, so a screen reader hears new lines without anything being re-read. The block gives up its bottom corners to it, so the two read as one box.

The capture is a small script the element writes first into the frame's head — before the deferred js urls and the sample's own module, so a top-level console.log on the first run is caught too. A sample that is a whole document owns its head, so it gets no hook and no strip. no-console silences the sample's chatter — the right call for a demo that logs on every frame.

An uncaught error is a line in the same strip, in sequence with whatever the sample logged on the way there: a broken sample is read from the order, not from one message on its own. It is the one line nobody asked for, so it is tinted, carries a ⚠, and is a role="alert" — announced assertively out of a region that is otherwise polite. That capture is written into the frame's head alongside the console hook, and for a sharper reason: the js pane is a type="module", so a top-level throw is over before the frame's load event, and a listener attached there never hears the one error worth hearing. no-console does not silence it — the strip is built for that line alone if it has to be.

The strip's height is --code-preview-console-height (default 10rem), scrolling past it.

Turning an options panel knob is exempt from all of it. The click is already the reader asking, and a knob that did nothing until a second press on Run would be a bug.

The two buttons

In the code block's bottom-left corner, two small buttons, in the order you reach for them — a glyph and the word for it, at the size of the tabs and the keyboard hint they share the element with:

Button
Edit opens the editor on the pane that is showing, and closes it again. A toggle
Run runs the sample again, edited or not. Only while the pane showing is one whose edits wait on it — the js pane, or a lone fence carrying its own <script>

Both are plain buttons; Edit fills in with the accent while the editor is open, because that one is a toggle and the fill is which way it is. Run stays plain — appearing at all is its statement, and on every other pane it is not there: edits there apply as they are typed, so the button would have nothing to do. The word on the button is its accessible name, so there is no aria-label or title that can come to disagree with it.

They sit on the block rather than in the strip above it because that is what they act on, and in the bottom-left corner because the top-right is where a docs theme has already put its copy button. This element leaves that button alone. Copying a code block is what a docs theme already does well, on every block on the page including the ones that are not samples, and a second button doing the same job is worse than either alone.

Ctrl/Cmd + Enter works in every open editor, not only the ones that wait — in a markup or css pane it means "stop waiting" and applies the pending debounce now.

Neither button appears on a sample with no editor in it. A no-edit sample is a code block: there is nothing to open and, since it can never change, nothing to re-run. The theme's own copy button is the whole of what it needs.

Both are on by default and no-actions takes them away, spelled the way no-edit is — bare for both, or naming the one to drop:

<code-preview no-actions></code-preview>
<code-preview no-actions="run"></code-preview>

Dropping Edit leaves Enter on the focused block as the only way in, which is the keyboard's route anyway — a pointer user loses the editor entirely. Dropping Run from a js sample leaves Ctrl/Cmd + Enter and closing the editor as the ways to apply an edit; both are keyed to what the sample is rather than to whether the button is there, so those edits are never stranded.

Several fences, several tabs

A sample that needs a stylesheet or a script is written as the separate blocks it is, and each one becomes a tab:

<code-preview css="dist/lib.css" js="dist/lib.js">
  <pre><code class="language-html">&lt;aside class="drawer"&gt;…&lt;/aside&gt;</code></pre>
  <pre><code class="language-css">.drawer { transition: transform 0.2s; }</code></pre>
  <pre><code class="language-js">document.querySelector(".drawer");</code></pre>
</code-preview>

There is nothing to configure. The language comes off the language-* class your site generator already writes on the block, html is the sample, css goes in the frame's head and js at the end of its body. A block in anything else — scss beside the css it compiles to, a json config the sample reads — still gets a tab, read-only, because there is nothing in the frame to type it into.

Pane Tab Where it lands
html HTML the frame's <body>
css CSS <style> last in <head>, so it wins over css urls
js JS <script type="module"> last in <body>

The js pane is a module, and that is not about scoping. A classic inline script runs while the parser is still going — before the deferred bundles from js have defined anything — so a sample that writes a property on a custom element gets one that has not upgraded yet, and the write installs an own property that shadows the accessor the class is about to bring. It fails silently and permanently. Modules are deferred and deferred scripts run in document order, so the pane runs after every url in js.

One fence is still one code block: no tab strip, no roles, nothing hidden. The markup pane is named code rather than html, so tab="code" keeps meaning the sample.

Two more panes are read-only for the same nowhere-to-type reason: any fence beside a sample that is a whole document — it owns its head and body, so the element has no place in it to write a stylesheet or a script — and a second fence in a language that already has a pane, which gets a numbered tab (CSS2) while the frame is built from the first.

Locking a pane

no-edit on its own is the whole sample; given panes to name it locks only those, so a demo whose markup is the point can keep its stylesheet and script as context to read:

<code-preview no-edit="css js"></code-preview>

Name a pane by what its tab says (html, css, js) or by the pane's own name (code for the markup one, as everywhere else). Panes left out stay editable.

A single fence can also say it itself, with no-edit on the block or on the <pre> around it:

<pre no-edit><code class="language-css">.drawer { transition: transform 0.2s; }</code></pre>

In markdown that needs no new vocabulary if your generator turns a bare word in the info string into a class on the block — which is what the class this reads is:

```css no-edit
.drawer { transition: transform 0.2s; }
```

The options panel

A second tab beside the code, with controls generated from a Custom Elements Manifest. It is opt-in — one more script tag, and nothing in either element bundle:

<script src="node_modules/code-preview-element/dist/code-preview-hljs.min.js"></script>
<script src="node_modules/code-preview-element/dist/code-preview-options.min.js"></script>

<code-preview
  css="dist/switch.css dist/switch-theme.css"
  js="dist/switch.js"
  manifest="dist/custom-elements.json"
  tab="options"
>
  <pre><code class="language-html">&lt;switch-elemental&gt;…&lt;/switch-elemental&gt;</code></pre>
</code-preview>

custom-elements.json is not a format invented here — it is the one the ecosystem already has, generated from your JSDoc by @custom-elements-manifest/analyzer, and it already carries everything a panel needs. cssProperties[].syntax is the Houdini syntax string (<color>, <time>, ease | linear), which is exactly a control type — so the CSS side is already typed and nobody has to agree with us about how. Shipping one also buys editor autocomplete and a Storybook args table, which a format of ours would not.

This package ships one of its own, on the same terms it asks of everyone else: the JSDoc block on CodePreview is the source, cem analyze runs in the build, and the result lands at dist/custom-elements.json as the customElements key. So the attributes and custom properties tabulated above are also readable by a tool — including this element, pointed at itself.

No manifest, no tabs. Every page that does not use one renders byte-identically.

Where a knob writes

The element's premise is that the code block's text is the single source of truth. A knob that quietly mutated the live DOM inside the frame would break it — the code tab would then describe something that is not what is rendered. So the two kinds of knob get two different answers:

Manifest field Control from Writes to
attributes[] type.text — boolean, number, union the sample source, spliced into its opening tag
cssProperties[] syntax<color>, <time>, union a stylesheet in the frame, plus a rule to copy
events[] nothing — it is a readout nothing. It counts what the sample fires

An attribute belongs to an element in the sample, so its knob rewrites the code above and the code tab keeps telling the truth. The splice is a regex over the opening tag rather than a parse-and-serialize: on a documentation page the markup is the documentation, and reformatting it on the first knob turn is not acceptable. Edit an attribute back by hand and the controls re-read it the next time the Options tab is opened.

A custom property is not part of the sample — a consumer setting one does it in their own stylesheet, so the panel does the same: one <style> appended last in the frame's head, holding one rule whose selector is the element's own tag. Never :root; a property set on an element beats one inherited from an ancestor, so a themed element would ignore it. That rule is printed at the bottom of the panel to be copied, which is worth more than the knobs are.

An event is not a knob at all. Everything in events[] is listed whether or not it has fired, and counted as it does, with the last detail beside the count — an element whose whole API is a CustomEvent is otherwise a preview that appears to do nothing when you click it. The listeners go on the frame's document, in the capture phase: capture is what hears an event that does not bubble, which is most of them, and the document is what survives the innerHTML patch a keystroke does. A rebuilt frame is a new document with a new sample in it, so the counts start again.

An untouched knob writes nothing. No attribute, no declaration. The manifest's default is a placeholder, not a value, so emptying a control is how you reset it.

Controls

Manifest says Control
attribute, boolean checkbox — on writes it bare, off removes it
attribute, 'a' | 'b' <select>, plus an empty option meaning unset
attribute, number <input type="number">
cssProperty, <color> text field plus a swatch beside it
cssProperty, a | b | c <select>
anything else, or nothing <input type="text">

<input type="color"> is deliberately never the color control on its own. currentcolor, Canvas, transparent and color-mix(in srgb, currentcolor 22%, transparent) are all real defaults in a themeable library, a native color input can hold none of them, and swapping one out for a hex value is how a knob silently destroys a theme that was correct. The text field is the control; the picker sits beside it and writes into it.

The swatch does follow the field, though, because it fills the whole button and a button showing black beside a field that says oklch(…) is a lie the size of the control. The value is resolved by setting it on the swatch and reading the computed color back, which is what turns a named color, hsl(…) or a color-mix(…) into channels. Two cases cannot be shown as a color, and neither is faked: transparent is drawn as a thin red cross over a black square, the way a mac shows no color — there is no transparent in a picker, and the alpha attribute newer browsers accept only buys #rrggbbaa, not the keyword — and anything the engine cannot resolve leaves the swatch where it was.

A property the manifest documents without a default falls back to what the frame computes for it, so an undocumented default still shows something true.

Anything the manifest cannot express goes in one namespaced key that every other tool ignores — the CEM schema sets no additionalProperties: false:

{
  "name": "--switch-elemental-duration",
  "syntax": "<time>",
  "default": "250ms",
  "x-code-preview": { "control": "range", "min": 0, "max": 1000, "step": 25 },
}

control overrides the table above, hidden drops the entry entirely — though the better place to leave a knob out is the manifest itself, by not documenting a property that is calc()-derived from the others.

Three builds

Same element in the first two, and the only difference is whether highlight.js rides along:

Bundle gzipped minified
dist/code-preview.min.js 7.5KB 21KB the default. No highlighter; uses window.hljs if the page has one.
dist/code-preview-hljs.min.js 23KB 63KB highlight.js bundled in, for a page with none.
dist/code-preview-options.min.js 3.7KB 9.3KB the options panel, on top of either. Carries no copy of the element.

Gzipped is what a server sends and so what the page actually pays; the minified column is there because it is the number a bundler's output listing shows you.

The default reads the global per call rather than once at startup, so the order of the two script tags does not matter:

<script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/highlight.min.js"></script>
<script src="node_modules/code-preview-element/dist/code-preview.min.js"></script>

A page that highlighted its fences at build time and ships no runtime hljs is the case the default cannot cover: the preview and the editor still work, the block keeps whatever color the generator baked in, and typing stops recoloring it. That page wants the -hljs build — it is the whole install in one tag, and the reason to reach for it is the editor, not the first paint.

The only thing the default build asks of that global is one method, so something other than highlight.js can stand in — Prism, Shiki's browser build, your own — 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. CodePreview.highlighter is the same hook one level down, typed as Highlighter, for code that registers the element itself rather than loading a bundle that does.

Language

Twelve strings go in front of a reader — two button labels, the keyboard hint in both of its states, and the accessible names on the tabs, the width switcher, the console and the block itself. A page in another language replaces them in one object, stated before the bundle's script tag:

<script>
  window.codePreviewStrings = {
    edit: "Izmeni",
    run: "Pokreni",
    hintClosed: "Pritisni Enter za izmenu",
    hintOpen: "Pritisni Esc za izlaz",
  };
</script>
<script src="node_modules/code-preview-element/dist/code-preview.min.js"></script>

Before, and not after, which is the one ordering rule here — and the one place this differs from window.hljs. 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 registers the element. There is no moment after that script tag in which to say what language the page is in.

Every key is optional. What you leave out keeps its English default, so translating the two buttons is a two-line change and not a commitment to the other ten:

Key Default Where it shows
edit Edit the button that opens the editor
run Run the button that applies a js edit
hintClosed Press Enter to edit under the block at rest
hintOpen Press Esc to stop editing under the block while the editor is open
fit Fit the first width button
widths Preview width the width group's accessible name
tablist Sample the tab strip's accessible name
actions Sample actions the button pair's accessible name
console Console the log strip's accessible name
scriptError Script error logged when a throw carries no message
sample {language} sample the block's accessible name
rendered Rendered {label} the frame's title

{language} and {label} are substituted where you put them, which is the reason they are placeholders rather than a value glued onto the end of a string — word order is the first thing a translation changes, and primer koda: {language} has to be sayable.

HTML, CSS, JS and 480px are deliberately not on the list: those are the names of the things themselves, and translating them would be getting them wrong.

Markup still outranks all of it. A <pre> or <code> that carries its own aria-label keeps it, exactly as it does without any of this — a docs page that has already named a sample knows more about it than either default.

From a bundler it is the same global and the same rule, with one wrinkle: import is hoisted, so an assignment written above it still runs after the bundle has registered the element. Either set it in a module imported first, or reach for the dynamic form:

globalThis.codePreviewStrings = { edit: "Izmeni", run: "Pokreni" };
await import("code-preview-element");

CodePreview.strings is the same object one level down, typed as Strings. Assigning it late is not useless — anything not yet built picks it up, and the buttons are built on demand — but it is not the way in, because the blocks already on the page are past the moment they read it.

From JavaScript

An element in the markup needs none of this — the bundles define it and it upgrades on its own. What is exported is the seam the options panel itself is built on, so a second panel, a different highlighter or a test can use the same one:

Export
define() registers code-preview, a no-op if it already is
CodePreview the class, for instanceof and for the two statics below
CodePreview.highlighter (element, language) => void — recolor a block, leave its text alone
CodePreview.options (host) => void, called once per element with a manifest. The panel sets it
CodePreview.strings Strings — every reader-facing string, partial. See Language
hljsHighlighter(hljs) builds a Highlighter from any hljs-shaped object
buildSrcdoc(html, { css, js, head }) the frame document as a string, without an element
scaleToFit(available, emulated) the scale factor viewport-width applies

And per instance, the surface the panel drives:

Member
source the markup pane's text. Assigning it retypes the block and re-renders
frameDocument the frame's document, once it holds one of ours — undefined until
setFrameStyle(css) one stylesheet appended last in the frame's head
toolbar the strip above the preview — the width switcher's. Created on first read
codeBar the strip above the code — the tabs' and the actions'. Created on first read
codePanel the element's own child that holds the markup pane's block
addPane(name, panel[, code, language]) register a pane and its tab. The options bundle is one caller
onPanelSync assign a callback: "re-read what you are showing", on tab and on frame load
import { CodePreview } from "code-preview-element";

document.querySelector("code-preview").source = "<p>Set from a script.</p>";

Everything above is typed; dist/*.d.ts ships in the package.

Styling

Two stylesheets ship in dist, both plain CSS, minified and not:

File
dist/code-preview.css required — the layout the element needs to work
dist/code-preview-hljs.css optional — highlight.js token colors, light and dark

The second is separate on purpose: a docs site that already ships a syntax theme should not have it overridden. Link it only if the code blocks would otherwise be monochrome. It is scoped to pre code, so it cannot reach code blocks elsewhere on the page. It shares a name with dist/code-preview-hljs.js and nothing else — one is hljs's colors, the other is hljs itself. Under the package exports they are ./theme and ./hljs.

The required sheet is the minimum plus as little taste as possible. Every color is a custom property with a fallback, and every one of them is namespaced:

Property Default What it colors
--code-preview-bg #fff bar, frame, options panel, controls
--code-preview-fg inherit the selected tab, a hovered one
--code-preview-fg-muted #656d76 tabs, buttons, labels, the hint
--code-preview-border #d8d8d8 every border in the element
--code-preview-accent #0969da focus rings, Edit while open, the tooltip, checkboxes and ranges
--code-preview-danger #cf222e error lines in the console and the transparent-swatch cross
--code-preview-radius 6px the outer corners; controls take half
--code-preview-font-mono ui-monospace, monospace every bit of text in the chrome

--code-preview-danger is the error lines in the console — the sample's own console.error, and the tinted role="alert" line an uncaught throw adds, which a screen reader hears at the moment the reader's edit causes it.

One property is written rather than read: --code-preview-tail is the measured height of the console strip, published on the element so the Edit and Run buttons in the code block's corner can be lifted clear of it. Read it if something of yours has to sit in that corner too; setting it only moves the buttons until the next measurement.

Each one falls back to its unprefixed name before its default — --code-preview-bg to --bg to #fff — so a host page that already defines --border, --bg, --accent, --fg, --fg-muted, --danger, --radius or --font-mono still gets its own look for free, and a page that wants to move this element alone sets the prefixed name:

:root {
  --bg: #0d1117;
} /* themes the page, and this element with it */

code-preview {
  --code-preview-bg: #161b22;
} /* moves this element only */

The element and the code block are meant to read as one object, so the preview has no bottom border (the code block below brings its own) and the block inside gets no margin. If a gap survives anyway, a host theme is outranking the package: its .prose :is(figure, .code-wrap) is two classes against the package's one class and one type. Win it back from the host side:

.prose code-preview > :is(pre, .code-wrap) {
  margin: 0;
}

max-height on the frame is load-bearing, not taste: the element sizes the frame from its content, so a sample measured in viewport units would grow the frame, which grows the viewport, which grows the sample. The cap makes that converge.

Reserved height

A preview's real height is its sample's, and nothing knows that until the frame has rendered. Every preview would therefore land after first paint and push whatever is below it down — the layout shift. So space is held for it before the element has upgraded and before the frame has loaded, from one variable:

code-preview {
  --code-preview-height: 8rem;
} /* the default */

--code-preview-bar-height (default 2.25rem) is the same for a toolbar strip. One is reserved above the code wherever the tabs are coming — a second fence, or a manifest — and a second above the preview wherever viewport-widths will put the width switcher there. A single-fence sample gets neither: it has no tabs, and its two buttons sit on the block rather than in a strip of their own.

--code-preview-options-height (default 12rem) is the third, and only matters with tab="options" — that is the one case where upgrading hides something, since the code block is visible until the panel exists to replace it. The stylesheet hides it from the start and holds room for the panel instead. It is a floor rather than a height, so it also covers the gap between the element upgrading and the manifest arriving.

The default is a guess centred on real samples rather than a round number — it measured lowest across the demo page. Set it per element wherever the height is actually known, and there is nothing left to guess:

<code-preview style="--code-preview-height: 320px"></code-preview>

Measured on the demo page, headless Chrome at 1200×900: CLS 0.0285 with the reservation at 4rem, 0.0167 at 12rem, 0.0022 at the 8rem default.

A second shift is possible after that one: a re-measure that comes back shorter than the last — a webfont or an image landing late, a narrower column scaling the frame down — pulls the page back up. no-shrink holds the tallest measurement instead, trading some empty space below a sample for a preview that never moves what is under it:

<code-preview css="dist/my-library.css" no-shrink></code-preview>

It is per element and off by default, because a sample whose height genuinely varies — an edit that deletes half the markup, a demo that toggles a panel — should follow its content down. A new source or a new viewport-width resets the remembered height either way.

Caching measured heights in localStorage was tried and removed. It bought nothing over a well-centred reservation, and a key can only name a stylesheet's url, not its contents — so editing a sample's css left every returning reader holding a remembered height that was quietly wrong.

Known limits

  • HTML samples only. Editing css or js separately means a run of fences per demo plus a tab strip. Not built.
  • No sandbox. Demo js runs and can reach the parent, same-origin. Fine for author-written docs plus self-typed edits — self-XSS only, nothing persisted or shared. Sharing a sample through a url would need sandbox="allow-scripts", which goes cross-origin-opaque and kills both the height measurement and the theme write, forcing a postMessage protocol.
  • Demo code is never built. It goes into a classic <script> verbatim: no TypeScript, no JSX, no bare imports, no top-level await.
  • Previews need JS. Without it the page is a plain code block.
  • Sizing and the patch-on-edit path have no automated test. jsdom has no layout and fires an iframe's load without rendering the srcdoc, so a test of either would assert fiction. npm run dev and the site are the check.

Development

npm run dev        # site/ on :4040, live reload
npm run build      # dist/ + _site/
npm test           # typecheck + node --test

The site is the poops-docs-theme prose layout, a dev dependency — so the demo doubles as the check that the element drops into a real docs theme: its tokens, its highlight.js colors, its copy buttons wrapping every pre. The one thing it has to say out loud is the margin override that code-preview.css documents.

License

MIT

About

A code block that renders itself: live, editable HTML samples in an isolated iframe.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages