Skip to main content

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
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('&');
}

Open this file in the repository

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
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>

Open this file in the repository

examples/html/src/app.ts
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'));
}

Open this file in the repository

examples/html/src/main.ts
examples/html/src/main.ts
import '@usezeno/tokens/css';
import '../../shared/page.css';
import { start } from './app.js';

start();

Open this file in the repository

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's event.detail is typed.
  • A controlled value or checked stays in step with state in both directions.
  • A wrong prop value or detail field is a type error.

To know:

  • onChange fires when a change is committed, as on the element. React's own onChange on <input> fires on every keystroke. Use onInput for 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
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>
);
}

Open this file in the repository

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-model works on zeno-input and zeno-select. It uses the value property and the input event, which zeno-textarea, zeno-slider, zeno-radio-group and zeno-date-picker have as well.
  • useCheckedModel(ref) binds checked on zeno-checkbox, zeno-switch and zeno-radio. Plain v-model also binds checked when the tag has type="checkbox" written on it.
  • :options="plans" sets the property.
  • @open, @close and other events work.
  • vue-tsc checks props and event handlers on zeno- 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-tsc does not check v-model on 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
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>

Open this file in the repository

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 on plus the event name, including onrequest-close, and event.preventDefault() on it keeps a modal open.
  • bind:this gives you the element, to call show() and close().
  • A wrong property value or detail field is an error in svelte-check.

What does not work:

  • bind:value and bind:checked. Svelte's compiler rejects them on a custom element (error bind_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 onclick on zeno-button, because Svelte cannot know it is a button. The example's svelte.config.js filters those two warnings for zeno- 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
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>

Open this file in the repository

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.