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
- Intended use
- Install
- How it works
- Attributes
- Responsive samples
- Wiring it up
- Editing — Editing is asked for, The two buttons
- Several fences, several tabs
- The options panel — Where a knob writes, Controls
- Three builds
- From JavaScript
- Styling — Reserved height
- Known limits
- Development
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-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 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.
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, 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.
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. |
npm install code-preview-elementA 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"><button class="btn">Hi</button></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 colorsImport 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.
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.
| 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="<style>body{margin:0;padding:2rem;font-family:system-ui}</style>"
>
<pre><code class="language-html"><p>Roomier, and not Times New Roman.</p></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.
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"><div class="container grid grid-gutter">…</div></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 flow —
position: 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.
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.
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.
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.
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 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.
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.
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.
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"><aside class="drawer">…</aside></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.
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; }
```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"><switch-elemental>…</switch-elemental></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.
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.
| 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:
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.
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.
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.
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.
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.
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.
- 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 needsandbox="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-levelawait. - 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
loadwithout rendering thesrcdoc, so a test of either would assert fiction.npm run devand the site are the check.
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.
MIT
{ "name": "--switch-elemental-duration", "syntax": "<time>", "default": "250ms", "x-code-preview": { "control": "range", "min": 0, "max": 1000, "step": 25 }, }