| name | building-a-html-element-sandbox-with-lit |
| description | Learn how to build a Lit web component to create a dynamic HTML element sandbox with live updates, perfect for experimenting with and showcasing web components. |
| metadata | {"url":"https://rodydavis.com/posts/html-code-sandbox","last_modified":"Tue, 03 Feb 2026 20:04:27 GMT"} |
Building a HTML Element Sandbox with Lit
In this article I will go over how to set up aĀ Lit web component and use it to create a HTML Element sandbox that can be used to update a live component.
TLDRĀ The final sourceĀ hereĀ and an onlineĀ demo.
PrerequisitesĀ
- Vscode
- Node >= 16
- Typescript
Getting StartedĀ
We can start off by navigating in terminal to the location of the project and run the following:
npm init @vitejs/app --template lit-ts
Then enter a project nameĀ html-element-sandbox and now open the project in vscode and install the dependencies:
cd html-element-sandbox
npm i lit
npm i -D @types/node
code .
Update theĀ vite.config.ts with the following:
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
base: "/html-element-sandbox/",
build: {
lib: {
entry: "src/html-element-sandbox.ts",
formats: ["es"],
},
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
});
TemplateĀ
Open up theĀ index.html and update it with the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HTML Element Sandbox</title>
<script type="module" src="/src/html-element-sandbox.ts"></script>
<style>
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}
html-element-sandbox {
display: block;
width: 100%;
height: 100vh;
}
</style>
</head>
<body>
<html-element-sandbox>
<template>
<button
class="button"
knob-text="label"
knob-css-color="fg-color"
knob-css-background-color="bg-color"
knob-css-border-radius="shape"
knob-css-font-size="text-font-size"
knob-css-padding="padding"
knob-css---shadow-color="shadow"
>
My Button
</button>
<style>
.button {
--shadow-color: #000;
--elevation: 3px;
display: block;
width: 100%;
height: 100%;
border: none;
background-color: transparent;
cursor: pointer;
box-shadow: 0 var(--elevation) calc(var(--elevation) * 2) 0 var(--shadow-color);
}
</style>
</template>
<div slot="knobs">
<knob-string id="label" name="Label" value="BUTTON"></knob-string>
<knob-group name="Style" expanded>
<knob-color
id="bg-color"
name="Background Color"
value="#ff0000"
></knob-color>
<knob-color
id="fg-color"
name="Foreground Color"
value="#ffffff"
></knob-color>
<knob-color
id="shadow"
name="Shadow Color"
value="#000000"
></knob-color>
<knob-number
id="text-font-size"
name="Font Size"
value="16"
suffix="px"
></knob-number>
<knob-number
id="shape"
name="Border Radius"
value="100"
suffix="px"
></knob-number>
<knob-number
id="padding"
name="Padding"
value="12"
suffix="px"
></knob-number>
</knob-group>
</div>
</html-element-sandbox>
</body>
</html>
Here we are defining the markup we want to use in our sandbox. We are using theĀ html-element-sandbox component to create a sandbox for our HTML Element.
<html-element-sandbox></html-element-sandbox>
Each knob is defined by anĀ idĀ and aĀ name. TheĀ idĀ is used to identify the knob in theĀ templateĀ and theĀ name is used to display the knob in the UI.
<knob-number
id="shape"
name="Border Radius"
value="30"
suffix="px"
></knob-number>
For the element inside theĀ templateĀ we useĀ knob-* attributes to get the values of the knobs and set the attributes, CSS style or text content.
<!-- Attributes -->
<div knob-attr-disabled="disabled"></div>
<knob-boolean id="disabled" name="Disable" value="false"></knob-boolean>
<!-- CSS Properties -->
<div knob-css-color="fg-color" knob-css-background-color="bg-color"></div>
<knob-color id="bg-color" name="Background Color" value="#ff0000"></knob-color>
<knob-color id="fg-color" name="Foreground Color" value="#ffffff"></knob-color>
<!-- Text Content -->
<div knob-text="content"></div>
<knob-string id="content" name="Text Content" value="Hello World"></knob-string>
A single knob can point to multiple elements:
<html-element-sandbox>
<template>
<div id="buttons">
<button
knob-text="label"
knob-css-color="fg-color"
knob-css-background-color="bg-color"
knob-css-border-radius="shape"
knob-css-font-size="text-font-size"
knob-css-padding="padding"
knob-css---shadow-color="shadow"
knob-attr-raised="raised"
knob-attr-contenteditable="contenteditable"
></button>
<mwc-button
knob-attr-label="label"
knob-css---mdc-theme-on-primary="fg-color"
knob-css---mdc-theme-primary="bg-color"
knob-css---mdc-shape-small="shape"
knob-attr-raised="raised"
label="My Button"
></mwc-button>
</div>
<script type="module">
import "https://www.unpkg.com/@material/[emailĀ protected]/mwc-button.js?module";
</script>
<style>
button {
--shadow-color: #000;
--elevation: 3px;
display: block;
border: none;
background-color: transparent;
cursor: pointer;
box-shadow: 0 var(--elevation) calc(var(--elevation) * 2) 0 var(--shadow-color);
}
mwc-button {
--mdc-theme-on-primary: #000;
--mdc-theme-primary: #fff;
--mdc-shape-small: none;
}
#buttons {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1rem;
}
</style>
</template>
<div slot="knobs">
<knob-string id="label" name="Label" value="BUTTON"></knob-string>
<knob-group name="Style" expanded>
<knob-color
id="bg-color"
name="Background Color"
value="#ff0000"
></knob-color>
<knob-color
id="fg-color"
name="Foreground Color"
value="#ffffff"
></knob-color>
<knob-color id="shadow" name="Shadow Color" value="#000000"></knob-color>
<knob-number
id="text-font-size"
name="Font Size"
value="16"
suffix="px"
></knob-number>
<knob-number
id="shape"
name="Border Radius"
value="30"
suffix="px"
></knob-number>
<knob-number
id="padding"
name="Padding"
value="12"
suffix="px"
></knob-number>
</knob-group>
<knob-group name="Attributes" expanded>
<knob-boolean id="raised" name="Raised" value="false"></knob-boolean>
<knob-list id="contenteditable" name="Content Editable" value="false">
<option value="true">true</option>
<option value="false">false</option>
</knob-list>
</knob-group>
</div>
</html-element-sandbox>
AĀ styleĀ andĀ scriptĀ can be added to load extra content into the sandbox (e.g. aĀ scriptĀ to load a web component).
Web ComponentĀ
Before we update our component we need to renameĀ my-element.tsĀ toĀ html-element-sandbox.ts
Open upĀ html-element-sandbox.ts and update it with the following:
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import "./knobs/boolean";
import "./knobs/string";
import "./knobs/number";
import "./knobs/color";
import "./knobs/list";
import "./knobs/group";
import { KnobValue } from "./knobs/base";
import { BooleanKnob } from "./knobs/boolean";
export const tagName = "html-element-sandbox";
@customElement(tagName)
export class HTMLElementSandbox extends LitElement {
static styles = css`
main {
--knobs-width: 300px;
--code-height: calc(100% * 0.4);
--mobile-height: 350px;
display: grid;
grid-template-areas: "preview" "knobs" "code";
grid-template-columns: 100%;
grid-template-rows: var(--mobile-height) auto auto;
height: 100%;
width: 100%;
}
#preview {
grid-area: preview;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-bottom: 1px solid #272727;
background-color: whitesmoke;
}
@media (min-width: 600px) {
main {
grid-template-areas:
"preview knobs"
"code knobs";
grid-template-columns: calc(100% - var(--knobs-width)) var(
--knobs-width
);
grid-template-rows: calc(100% - var(--code-height)) var(--code-height);
}
#preview {
border-bottom: none;
}
slot[name="knobs"] {
overflow-y: auto;
}
pre {
overflow-y: scroll;
}
}
section {
flex: 1;
}
slot[name="knobs"] {
grid-area: knobs;
display: flex;
flex-direction: column;
border-left: 1px solid #000;
}
slot[name="code"] {
grid-area: code;
}
pre {
margin: 0;
font-family: Monaco, Courier, monospace;
padding: 16px;
background-color: #272727;
color: #c8c8c8;
}
code {
font-size: 0.8rem;
white-space: pre-wrap;
}
`;
@state() code = "";
render() {
return html`<main>
<section id="preview">
<slot></slot>
</section>
<slot name="knobs"> </slot>
<slot name="code">
<pre><code>${this.code}</code></pre>
</slot>
</main>`;
}
firstUpdated() {
this.init();
}
init() {
this.setUpKnobs();
this.code = this.getCode();
// Update the code every time a knob value changes
this.addEventListener("value", () => {
this.code = this.getCode();
});
}
setUpKnobs() {
const root = this.shadowRoot!;
const preview = root.getElementById("preview")!;
const template = this.querySelector("template");
if (template) {
const div = document.createElement("div");
div.appendChild(template.content.cloneNode(true));
// Text Knobs (knob-text)
div.querySelectorAll("[knob-text]").forEach((el) => {
const elemId = el.getAttribute("knob-text") || "";
const knob = this.querySelector(`#${elemId}`);
if (knob && knob instanceof KnobValue) {
knob.addEventListener("value", () => {
const val = knob.value;
el.textContent = val;
});
el.addEventListener("input", (e) => {
const target = e.target as HTMLElement;
knob.value = target.textContent;
});
knob.init();
}
});
div.querySelectorAll("*").forEach((el) => {
const attrs = el.attributes;
for (let i = 0; i < attrs.length; i++) {
const attr = attrs[i];
const attrName = attr.name;
// CSS Knobs (knob-css-*)
if (attrName.startsWith("knob-css-")) {
const cssKey = attrName.replace("knob-css-", "");
const knob = this.querySelector(`#${attr.value}`);
if (
knob &&
knob instanceof KnobValue &&
el instanceof HTMLElement
) {
knob.addEventListener("value", () => {
const val = knob.value;
if (knob.hasAttribute("suffix")) {
// Add suffix to the value (e.g. px)
el.style.setProperty(
cssKey,
val + knob.getAttribute("suffix")
);
} else {
// No suffix, just set the value
el.style.setProperty(cssKey, val);
}
});
knob.init();
}
}
// Attribute Knobs (knob-attr-*)
if (attrName.startsWith("knob-attr-")) {
const attrKey = attrName.replace("knob-attr-", "");
const knob = this.querySelector(`#${attr.value}`);
if (knob && knob instanceof KnobValue) {
knob.addEventListener("value", () => {
const val = knob.value;
if (knob instanceof BooleanKnob) {
if (val) {
// <div hidden>
el.setAttribute(attrKey, "");
} else {
// <div>
el.removeAttribute(attrKey);
}
} else {
// <div value="foo">
el.setAttribute(attrKey, val);
}
});
knob.init();
}
}
}
});
preview.appendChild(div);
}
}
getCode() {
const root = this.shadowRoot!;
const preview = root.getElementById("preview")!;
if (preview.children.length > 0) {
const child = preview.children[1];
if (child && child.children.length > 0) {
const lines = this.elementToString(child.children[0]);
// Trim empty lines
const linesArray = lines.split("\n");
const filteredLines = linesArray.filter((line) => line.trim() !== "");
return filteredLines.join("\n");
}
}
return "";
}
elementToString(node: Element) {
const sb: string[] = [];
const tag = node.tagName.toLowerCase();
sb.push(`<${tag}`);
const attrs = node.attributes;
// Add attributes
for (let i = 0; i < attrs.length; i++) {
const attr = attrs[i];
if (attr.name.startsWith("knob-")) continue;
// If the attribute is a boolean attribute, add it only if it's true
if (attr.value === "") {
sb.push(` ${attr.name}`);
} else {
sb.push(` ${attr.name}="${attr.value}"`);
}
}
sb.push(">");
if (node.childNodes.length > 0) {
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i];
// If the child is a text node, add the content
if (child instanceof Text) {
sb.push(child.textContent || "");
} else if (child instanceof Element) {
// If the child is an element, recurse
sb.push(this.elementToString(child));
}
}
}
sb.push(`</${tag}>`);
return sb.join("\n");
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: HTMLElementSandbox;
}
}
KnobsĀ
First let up create a base class that will be used to create all other knobs. CreateĀ src/knobs/base.ts and update with with the following:
import { css, html, LitElement, TemplateResult } from "lit";
import { property } from "lit/decorators.js";
export class Knob extends LitElement {
constructor(name: string) {
super();
this.name = name;
}
@property() name: string;
}
export abstract class KnobValue<T> extends Knob {
constructor(name: string, public val: T) {
super(name);
this._value = val;
this.notify();
}
static styles = css`
.knob {
display: flex;
flex-direction: row;
align-items: center;
padding: 0.5rem;
}
.knob label {
flex: 1;
}
`;
_value: T;
get value(): T {
return this._value;
}
set value(value: T) {
this._value = value;
this.notify();
}
notify() {
const value = this.value;
this.onValue(value);
this.dispatchEvent(
new CustomEvent("value", {
detail: value,
bubbles: true,
composed: true,
})
);
this.requestUpdate();
}
render() {
return html`
<div class="knob">
<label>${this.name}</label>
${this.buildInput()}
</div>
`;
}
onValue(_val: T) {}
init() {
this.notify();
}
resolveValue(val: T) {
return val;
}
abstract buildInput(): TemplateResult;
}
Boolean knobĀ
CreateĀ src/knobs/boolean.ts and update with the following:
import { KnobValue } from "./base";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
export const tagName = "knob-boolean";
@customElement(tagName)
export class BooleanKnob extends KnobValue<boolean> {
constructor(name: string, val: boolean) {
super(name, val);
}
static styles = KnobValue.styles;
@property({
type: Boolean,
attribute: "value",
})
_value = false;
buildInput() {
return html`<input
type="checkbox"
.checked=${this.resolveValue(this.value)}
@change=${this.onChange}
/>`;
}
onChange(e: Event) {
const target = e.target as HTMLInputElement;
this.value = target.checked;
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: BooleanKnob;
}
}
Number KnobĀ
CreateĀ src/knobs/number.ts and update with the following:
import { KnobValue } from "./base";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
export const tagName = "knob-number";
@customElement(tagName)
export class NumberKnob extends KnobValue<number> {
constructor(name: string, val: number) {
super(name, val);
}
static styles = KnobValue.styles;
@property({
type: Number,
attribute: "value",
converter: {
fromAttribute: (val: string) => parseFloat(val),
toAttribute: (val: boolean) => val.toString(),
},
})
_value = 0;
buildInput() {
return html`<input
type="number"
.valueAsNumber=${this.resolveValue(this.value)}
@change=${this.onChange}
/>`;
}
onChange(e: Event) {
const target = e.target as HTMLInputElement;
this.value = target.valueAsNumber;
}
resolveValue(val: number): number {
return val;
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: NumberKnob;
}
}
String KnobĀ
CreateĀ src/knobs/string.ts and update with the following:
import { KnobValue } from "./base";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
export const tagName = "knob-string";
@customElement(tagName)
export class StringKnob extends KnobValue<string> {
constructor(name: string, val: string) {
super(name, val);
}
static styles = KnobValue.styles;
@property({ type: String, attribute: "value" })
_value = "";
buildInput() {
return html`<input
type="text"
.value=${this.resolveValue(this.value)}
@input=${this.onChange}
/>`;
}
onChange(e: Event) {
const target = e.target as HTMLInputElement;
this.value = target.value;
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: StringKnob;
}
}
Color KnobĀ
CreateĀ src/knobs/color.ts and update with the following:
import { html } from "lit";
import { customElement } from "lit/decorators.js";
import { StringKnob } from "./string";
export const tagName = "knob-color";
@customElement(tagName)
export class ColorKnob extends StringKnob {
buildInput() {
return html`<input
type="color"
.value=${this.resolveValue(this.value)}
@input=${this.onChange}
/>`;
}
resolveValue(value: string) {
if (value && value.startsWith("--")) {
const style = getComputedStyle(document.body);
const resolved = style.getPropertyValue(value);
return resolved;
}
return value;
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: ColorKnob;
}
}
List KnobĀ
CreateĀ src/knobs/list.ts and update with the following:
import { KnobValue } from "./base";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
export const tagName = "knob-list";
@customElement(tagName)
export class ListKnob extends KnobValue<string> {
constructor(name: string, val: string) {
super(name, val);
}
static styles = KnobValue.styles;
@property({
type: String,
attribute: "value",
})
_value = "";
buildInput() {
const options = this.getOptions();
return html`<select @change=${this.onChange}>
${Array.from(options).map(
(option) =>
html`<option
value=${option.value}
.selected=${this.value === option.value}
>
${option.textContent}
</option>`
)}
</select>`;
}
getOptions() {
const options = this.querySelectorAll(
"option"
) as NodeListOf<HTMLOptionElement>;
return Array.from(options);
}
onChange(e: Event) {
const target = e.target as HTMLSelectElement;
this.value = target.value;
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: ListKnob;
}
}
Group KnobĀ
CreateĀ src/knobs/group.ts and update with the following:
import { css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Knob } from "./base";
export const tagName = "knob-group";
@customElement(tagName)
export class GroupKnob extends Knob {
constructor(name: string, knobs: Knob[] = []) {
super(name);
this.knobs = knobs;
}
static styles = css`
details {
display: flex;
flex-direction: column;
align-items: flex-start;
}
details summary {
padding: 0.5rem;
}
`;
knobs: Knob[];
@property({ type: Boolean }) expanded = false;
render() {
return html`<details ?open=${this.expanded}>
<summary>${this.name}</summary>
<div class="collection">
<slot></slot>
${this.knobs.map((knob) => html`${knob}`)}
</div>
</details>`;
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: GroupKnob;
}
}
ConclusionĀ
If everything worked as expected, you should see the following:

If you want to learn more about building with Lit you can read the docsĀ here.
The source for this example can be foundĀ here.