Frameworks
There is one implementation of each element. This page says how each
framework reaches it, what works, and what does not. Every statement marked
"tested" is covered by the example app for that framework in the examples
folder.
The same form, four times
Each example app builds the same sign-up form: a text field, a select, a checkbox, three buttons and a modal. One shared set of eight checks runs against all four in headless Chromium: properties reach the element, typing updates the app's state, the app's state updates the element, the modal opens and closes, the form submits the right values, and a required field blocks an empty submit.
The source shown under each heading below is read from the repository when this site is built. All four use this list of plans:
examples/shared/plans.ts
import type { SelectOption } from '@usezeno/core/web-components';
export const plans: SelectOption[] = [
{ value: 'free', label: 'Free' },
{ value: 'team', label: 'Team' },
{ value: 'company', label: 'Company' },
];
/** What a form would send, as `name=value` pairs. */
export function submission(form: HTMLFormElement): string {
return [...new FormData(form)]
.map(([name, value]) => `${name}=${String(value)}`)
.join('&');
}
Plain HTML
Load the tokens and the elements, then write the tags. See Using the elements. Arrays and objects are set as properties from script.
The sign-up form:
examples/html/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zeno in plain HTML</title>
<script type="module" src="./src/main.ts"></script>
</head>
<body>
<main class="sign-up">
<h1>Create an account</h1>
<form>
<zeno-input
name="email"
type="email"
label="Email"
required
></zeno-input>
<zeno-select name="plan" label="Plan"></zeno-select>
<zeno-checkbox
name="updates"
label="Send me product updates"
></zeno-checkbox>
<div class="actions">
<zeno-button type="submit">Create account</zeno-button>
<zeno-button id="show-details" variant="secondary">
Plan details
</zeno-button>
<zeno-button id="fill" variant="ghost">
Fill with an example
</zeno-button>
</div>
</form>
<p class="readout" data-testid="state"></p>
<p class="readout" data-testid="submitted"></p>
<p class="readout">
The details are <span data-testid="modal-state">closed</span>.
</p>
<zeno-modal heading="Plan details">
Free covers one project. Team and Company add shared projects.
<zeno-button slot="footer" id="close-details">Close</zeno-button>
</zeno-modal>
</main>
</body>
</html>
examples/html/src/app.ts
// No framework: the elements are used the way any HTML element is, with
// properties, attributes and addEventListener.
import '@usezeno/core/web-components';
import type {
ZenoCheckbox,
ZenoInput,
ZenoModal,
ZenoSelect,
} from '@usezeno/core/web-components';
import { plans, submission } from '../../shared/plans.js';
export function start(root: ParentNode = document): void {
const find = <T extends Element>(selector: string): T => {
const found = root.querySelector<T>(selector);
if (!found) throw new Error(`Nothing matches ${selector}`);
return found;
};
const form = find<HTMLFormElement>('form');
const email = find<ZenoInput>('zeno-input');
const plan = find<ZenoSelect>('zeno-select');
const updates = find<ZenoCheckbox>('zeno-checkbox');
const modal = find<ZenoModal>('zeno-modal');
const state = find<HTMLElement>('[data-testid="state"]');
const submitted = find<HTMLElement>('[data-testid="submitted"]');
const modalState = find<HTMLElement>('[data-testid="modal-state"]');
// An array cannot be written as an attribute, so it is set as a property.
plan.options = plans;
plan.value = 'free';
const showState = (): void => {
state.textContent = `email=${email.value} plan=${plan.value} updates=${
updates.checked ? 'yes' : 'no'
}`;
};
showState();
// `input` bubbles from every form control, as it does from native ones.
form.addEventListener('input', showState);
find('#fill').addEventListener('click', () => {
email.value = 'ada@example.com';
plan.value = 'team';
updates.checked = true;
showState();
});
form.addEventListener('submit', event => {
event.preventDefault();
submitted.textContent = `Submitted: ${submission(form)}`;
});
find('#show-details').addEventListener('click', () => modal.show());
find('#close-details').addEventListener('click', () => modal.close());
modal.addEventListener('open', () => (modalState.textContent = 'open'));
modal.addEventListener('close', () => (modalState.textContent = 'closed'));
}
examples/html/src/main.ts
import '@usezeno/tokens/css';
import '../../shared/page.css';
import { start } from './app.js';
start();
React
Use @usezeno/react. It has one component per element, generated from the
elements with @lit/react. React 18 and 19 are supported.
import { ZenoInput } from '@usezeno/react';
<ZenoInput
label="Email"
value={email}
onInput={event => setEmail(String(event.detail.value))}
/>;
Tested with React 19:
- Props are set as properties, so arrays and objects pass through.
- Events are props:
onInput,onChange,onOpen,onRequestClose. The handler'sevent.detailis typed. - A controlled
valueorcheckedstays in step with state in both directions. - A wrong prop value or
detailfield is a type error.
To know:
onChangefires when a change is committed, as on the element. React's ownonChangeon<input>fires on every keystroke. UseonInputfor that.- React 19 can also render the tags directly, without this package. You then have no prop or event types.
The sign-up form:
examples/react/src/App.tsx
// The elements through @usezeno/react: properties are typed props, and each
// event of an element is an `on…` prop whose event carries a typed detail.
import { useRef, useState } from 'react';
import {
ZenoButton,
ZenoCheckbox,
ZenoInput,
ZenoModal,
ZenoSelect,
type ZenoModalElement,
} from '@usezeno/react';
import { plans, submission } from '../../shared/plans.js';
export function App() {
const [email, setEmail] = useState('');
const [plan, setPlan] = useState('free');
const [updates, setUpdates] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [modalState, setModalState] = useState('closed');
const [submitted, setSubmitted] = useState('');
const modal = useRef<ZenoModalElement>(null);
return (
<main className="sign-up">
<h1>Create an account</h1>
<form
onSubmit={event => {
event.preventDefault();
setSubmitted(`Submitted: ${submission(event.currentTarget)}`);
}}
>
<ZenoInput
name="email"
type="email"
label="Email"
required
value={email}
onInput={event => setEmail(String(event.detail.value))}
/>
<ZenoSelect
name="plan"
label="Plan"
options={plans}
value={plan}
onChange={event => setPlan(String(event.detail.value))}
/>
<ZenoCheckbox
name="updates"
label="Send me product updates"
checked={updates}
onChange={event => setUpdates(event.detail.checked)}
/>
<div className="actions">
<ZenoButton type="submit">Create account</ZenoButton>
<ZenoButton
id="show-details"
variant="secondary"
onClick={() => setDetailsOpen(true)}
>
Plan details
</ZenoButton>
<ZenoButton
id="fill"
variant="ghost"
onClick={() => {
setEmail('ada@example.com');
setPlan('team');
setUpdates(true);
}}
>
Fill with an example
</ZenoButton>
</div>
</form>
<p className="readout" data-testid="state">
email={email} plan={plan} updates={updates ? 'yes' : 'no'}
</p>
<p className="readout" data-testid="submitted">
{submitted}
</p>
<p className="readout">
The details are <span data-testid="modal-state">{modalState}</span>.
</p>
<ZenoModal
ref={modal}
heading="Plan details"
open={detailsOpen}
onOpen={() => setModalState('open')}
onClose={() => {
setDetailsOpen(false);
setModalState('closed');
}}
>
Free covers one project. Team and Company add shared projects.
<ZenoButton slot="footer" onClick={() => modal.current?.close()}>
Close
</ZenoButton>
</ZenoModal>
</main>
);
}
Vue
Use the elements directly. @usezeno/vue/elements has the three things Vue
needs.
// vite.config.ts
import vue from '@vitejs/plugin-vue';
import { zenoCompilerOptions } from '@usezeno/vue/elements';
export default {
plugins: [vue({ template: { compilerOptions: zenoCompilerOptions } })],
};
<zeno-input v-model="email" label="Email" />
<zeno-select v-model="plan" :options="plans" label="Plan" />
<zeno-checkbox v-bind="useCheckedModel(updates)" label="Send me updates" />
Tested with Vue 3.5:
v-modelworks onzeno-inputandzeno-select. It uses thevalueproperty and theinputevent, whichzeno-textarea,zeno-slider,zeno-radio-groupandzeno-date-pickerhave as well.useCheckedModel(ref)bindscheckedonzeno-checkbox,zeno-switchandzeno-radio. Plainv-modelalso bindscheckedwhen the tag hastype="checkbox"written on it.:options="plans"sets the property.@open,@closeand other events work.vue-tscchecks props and event handlers onzeno-tags.
To know (from Vue's source and vue-tsc runs, not from tests):
- Import the elements before the component renders. Before an element is registered Vue sets attributes, and an array becomes text.
- Write a bound boolean in camelCase:
:hideRequiredIndicator="false". The kebab-case form sets the attribute text"false", which the element reads as true. vue-tscdoes not checkv-modelon a custom element and does not flag an unknown prop.
@usezeno/vue also still exports Vue components from 2025 (ZButton,
ZInput, …). They are a second implementation and are being retired. Use the
elements for new work.
The sign-up form:
examples/vue/src/App.vue
<script setup lang="ts">
// The Lit elements used directly in a Vue template. vite.config.ts tells Vue
// that zeno-* tags are custom elements. The import of @usezeno/vue/elements
// also brings the template types for them.
import '@usezeno/core/web-components';
import { ref, useTemplateRef } from 'vue';
import type { ZenoModal } from '@usezeno/core/web-components';
import { useCheckedModel } from '@usezeno/vue/elements';
import { plans, submission } from '../../shared/plans.js';
const email = ref('');
const plan = ref('free');
const updates = ref(false);
const updatesBinding = useCheckedModel(updates);
const detailsOpen = ref(false);
const modalState = ref('closed');
const submitted = ref('');
const modal = useTemplateRef<ZenoModal>('modal');
function submit(event: Event): void {
submitted.value = `Submitted: ${submission(event.target as HTMLFormElement)}`;
}
function fill(): void {
email.value = 'ada@example.com';
plan.value = 'team';
updates.value = true;
}
function closed(): void {
detailsOpen.value = false;
modalState.value = 'closed';
}
</script>
<template>
<main class="sign-up">
<h1>Create an account</h1>
<form @submit.prevent="submit">
<!-- v-model on a custom element binds `value` and listens for `input`. -->
<zeno-input
v-model="email"
name="email"
type="email"
label="Email"
required
/>
<zeno-select v-model="plan" name="plan" label="Plan" :options="plans" />
<zeno-checkbox
v-bind="updatesBinding"
name="updates"
label="Send me product updates"
/>
<div class="actions">
<zeno-button type="submit">Create account</zeno-button>
<zeno-button
id="show-details"
variant="secondary"
@click="detailsOpen = true"
>
Plan details
</zeno-button>
<zeno-button id="fill" variant="ghost" @click="fill">
Fill with an example
</zeno-button>
</div>
</form>
<p class="readout" data-testid="state">
email={{ email }} plan={{ plan }} updates={{ updates ? 'yes' : 'no' }}
</p>
<p class="readout" data-testid="submitted">{{ submitted }}</p>
<p class="readout">
The details are <span data-testid="modal-state">{{ modalState }}</span
>.
</p>
<zeno-modal
ref="modal"
heading="Plan details"
:open="detailsOpen"
@open="modalState = 'open'"
@close="closed"
>
Free covers one project. Team and Company add shared projects.
<zeno-button slot="footer" @click="modal?.close()">Close</zeno-button>
</zeno-modal>
</main>
</template>
Svelte
Use @usezeno/svelte. Importing it registers the elements and types them for
svelte-check.
<zeno-input
label="Email"
value={email}
oninput={event => (email = String(event.detail.value))}
></zeno-input>
Tested with Svelte 5:
- Properties are set as properties, so arrays and objects pass through.
- Events work as
onplus the event name, includingonrequest-close, andevent.preventDefault()on it keeps a modal open. bind:thisgives you the element, to callshow()andclose().- A wrong property value or
detailfield is an error insvelte-check.
What does not work:
bind:valueandbind:checked. Svelte's compiler rejects them on a custom element (errorbind_invalid_target). Two-way binding is the property plus the event, as in the example above. This is tested too.- Svelte's accessibility hints warn about
onclickonzeno-button, because Svelte cannot know it is a button. The example'ssvelte.config.jsfilters those two warnings forzeno-tags.
To know (from Svelte's source, not from a test): import @usezeno/svelte
before the component renders. Before an element is registered Svelte sets
strings, numbers and booleans as attributes, and false becomes the text
"false", which the element reads as true.
The sign-up form:
examples/svelte/src/App.svelte
<script lang="ts">
// The Lit elements used directly in Svelte markup. Importing @usezeno/svelte
// registers them and brings the markup types.
//
// Svelte only allows bind:value on <input>, <textarea> and <select>, so a
// two-way binding is written as its two halves: the property and the event.
import '@usezeno/svelte';
import type { ZenoModal } from '@usezeno/svelte';
import { plans, submission } from '../../shared/plans.js';
let email = $state('');
let plan = $state('free');
let updates = $state(false);
let detailsOpen = $state(false);
let modalState = $state('closed');
let submitted = $state('');
let modal: ZenoModal | undefined = $state();
function fill(): void {
email = 'ada@example.com';
plan = 'team';
updates = true;
}
</script>
<main class="sign-up">
<h1>Create an account</h1>
<form
onsubmit={event => {
event.preventDefault();
submitted = `Submitted: ${submission(event.currentTarget)}`;
}}
>
<zeno-input
name="email"
type="email"
label="Email"
required
value={email}
oninput={event => (email = String(event.detail.value))}
></zeno-input>
<zeno-select
name="plan"
label="Plan"
options={plans}
value={plan}
onchange={event => (plan = String(event.detail.value))}
></zeno-select>
<zeno-checkbox
name="updates"
label="Send me product updates"
checked={updates}
onchange={event => (updates = event.detail.checked)}
></zeno-checkbox>
<div class="actions">
<zeno-button type="submit">Create account</zeno-button>
<zeno-button
id="show-details"
variant="secondary"
onclick={() => (detailsOpen = true)}
>
Plan details
</zeno-button>
<zeno-button id="fill" variant="ghost" onclick={fill}>
Fill with an example
</zeno-button>
</div>
</form>
<p class="readout" data-testid="state">
email={email} plan={plan} updates={updates ? 'yes' : 'no'}
</p>
<p class="readout" data-testid="submitted">{submitted}</p>
<p class="readout">
The details are <span data-testid="modal-state">{modalState}</span>.
</p>
<zeno-modal
bind:this={modal}
heading="Plan details"
open={detailsOpen}
onopen={() => (modalState = 'open')}
onclose={() => {
detailsOpen = false;
modalState = 'closed';
}}
>
Free covers one project. Team and Company add shared projects.
<zeno-button slot="footer" onclick={() => modal?.close()}>Close</zeno-button>
</zeno-modal>
</main>
Server rendering
Not covered yet. The elements render in the browser. On a server-rendered page they appear once their script has loaded. Nothing in Zeno has been tested with Next.js, Nuxt or SvelteKit server rendering.