Loading the documentation…
Zx is a component library for building business software: dense data screens, long forms, modal workflows, and the application shells around them. It ships as plain ES2022 modules with no runtime dependencies — no framework, no bundler, no package to keep on a security treadmill. The same source you read here runs unchanged in the browser.
Everything is built on native platform features: <dialog>, the popover
attribute, ElementInternals, :has(), and CSS custom properties.
Components follow the WAI-ARIA Authoring Practices patterns, so keyboard support and screen
reader semantics come from the pattern rather than from bolted-on attributes.
Building with an AI agent? llms.txt is the machine-readable index, and docs/llms.md is the full agent reference — the same text the Reference tab shows for each component.
Evergreen browsers from roughly 2023 onwards: Chrome and Edge 114+, Firefox 121+, Safari 17+. There is no transpilation step and no polyfill bundle.
npm run build writes four artefacts to dist/. Pick the one that
matches how the host application loads code:
| File | Format | Use it when |
|---|---|---|
zx.esm.js |
ES module | The application uses import — the default choice. |
zx.global.js |
IIFE → window.zx |
A classic screen with plain <script> tags. |
zx-compat.global.js |
IIFE → window.gx |
Legacy gx code should keep running unchanged. |
zx.css |
Stylesheet | Always. Load it once per document. |
<link rel="stylesheet" href="/assets/zx.css">
<body class="zx-scope">
<script type="module">
import { Table, Message } from '/assets/zx.esm.js';
const table = new Table('#records', {
columns: [
{ id: 'number', label: 'Number', sortable: true, width: '1fr' },
{ id: 'customer', label: 'Customer', sortable: true, width: '2fr' }
],
data: records,
rowId: 'ID',
sortMode: 'local',
onrowclick: (event) => Message.info('Opening ' + event.detail.row.number)
});
</script>
</body>
The global bundle exposes the identical API under window.zx, which is what
classic ZeyOS screens use.
<link rel="stylesheet" href="/assets/zx.css">
<script src="/assets/zx.global.js"></script>
<script>
var toggle = new zx.Toggle(null, { label: 'Notifications', checked: true });
document.body.append(toggle.toElement());
</script>
zx-scope class
Base typography, box sizing, and the default chrome for unstyled native controls live under
.zx-scope. Put it on <body> — or on a single container when
Zx has to coexist with another design system on the same page.
npm run serve # http://127.0.0.1:8321/website/docs.html
npm test # unit tests + design-token lint
npm run build # dist/ bundles
No build step is required to develop: the server serves the source modules and the browser imports them directly.
Every component takes the same two arguments: a target and an options object.
new Component(target, options)
target can be:
querySelector;null — the component builds and owns its own root, which
you retrieve with .toElement() and place yourself.
import { Select, Message } from '/assets/zx.esm.js';
const select = new Select(null, {
items: [
{ ID: 'open', name: 'Open' },
{ ID: 'paid', name: 'Paid' }
],
value: 'open',
filter: 'local',
placeholder: 'Find a status'
});
document.querySelector('#status').append(select.toElement());
Subscribe with .on(type, handler), or pass an on<type>
option to the constructor. Handlers receive a CustomEvent whose
detail is a plain object. Event names are lowercase and carry no
on prefix.
select.on('change', (event) => {
Message.success('Status: ' + event.detail.item.name);
});
// …identical to:
new Select(null, {
onchange: (event) => Message.success('Status: ' + event.detail.item.name)
});
Each emit also dispatches a bubbling, composed zx-<type> event on the
component's root element, so an ancestor can listen without holding the instance:
document.querySelector('#workspace').addEventListener('zx-change', (event) => {
console.log(event.target, event.detail);
});
Call .destroy() when a screen closes. It aborts every listener the component
registered and removes the nodes it created, leaving the DOM as it found it. Creating and
destroying a component twice on the same target is safe.
select.destroy();
h() builds elements, icon() returns an inline SVG, and
button() is a plain factory rather than a class. See
Helper functions for the full surface.
import { h, button, icon } from '/assets/zx.esm.js';
const toolbar = h('div', { class: 'toolbar' },
button({ label: 'New invoice', kind: 'primary', icon: 'plus', onclick: create }),
button({ label: 'Refresh', icon: 'reload', onclick: reload })
);
Set data-zx-theme (light, dark, auto)
and data-zx-density (cozy, compact) on any ancestor —
usually <html>. Both cascade, so a single dense panel inside a cozy
application is just another attribute.
<html data-zx-theme="dark" data-zx-density="compact">
Colours are defined once as a tier-1 palette
(--zx-gray-*, --zx-green-*, …) and mapped to
tier-2 semantic tokens that describe a role rather than a hue. Components —
and application CSS — may only use the semantic tier. tests/lint-tokens.js
enforces this, which is what makes retheming a one-file change.
| Token | Role |
|---|---|
--zx-color-bg-page | Application background |
--zx-color-bg-surface | Cards, panels, headers |
--zx-color-bg-raised | Menus, popovers, toolbars |
--zx-color-bg-muted | Inline code, table headers |
--zx-color-bg-hover | Hover and pressed fills |
--zx-color-bg-selected | Selected rows and items |
--zx-color-border | Structural separators |
--zx-color-border-control | Input and button outlines |
--zx-color-text / -muted | Body and secondary text |
--zx-color-accent | Primary actions and selection |
--zx-color-danger / -warning / -success / -info | Status colours, each with a matching -bg |
--zx-control-height / -radius / -pad-x | Control geometry |
--zx-focus-ring | The :focus-visible box shadow |
The Design tokens entry renders every one of them live.
Override semantic tokens under a named theme attribute. Nothing else changes — every component picks the new values up.
[data-zx-theme="acme"] {
color-scheme: light;
--zx-color-accent: #2a66ab;
--zx-color-accent-hover: #1d4c80;
--zx-color-on-accent: #ffffff;
--zx-color-bg-page: #f7f8fa;
--zx-radius: 0.25rem;
}
The stock light and dark themes follow the shadcn/ui Nova · Zinc preset: a Zinc
neutral ramp, 0.45rem corner radius, Inter, 32px controls, and a three-pixel
translucent focus ring. The accent is the ZeyOS green, tuned across the preset's Emerald
ramp so it stays legible on both surfaces.
ZeyOS module colours ship as --zx-module-* tokens.
MasterPanel and NavigationBar accept a module option
(billing, projects, tickets, …) and colour their
header accordingly.
Business data comes from the dedicated ZeyOS client library,
@zeyos/client: a zero-dependency ESM OpenAPI client covering
the whole ZeyOS surface. Zx does not wrap it, bundle it, or depend on it — you construct it
and hand it in.
import { createZeyosClient } from '@zeyos/client';
const client = createZeyosClient({
platform: 'https://cloud.zeyos.com/your-instance/',
auth: { mode: 'session', session: { enabled: true, credentials: 'include' } }
});
const invoices = await client.api.listTransactions({ limit: 25 });
Note the ZeyOS naming: invoices live under transactions and customers under
accounts. Client instances are frozen — never attach properties to them.
src/zeyos/ is an optional, injection-based layer between Zx and the client. It
reads client.schema at runtime and generates fully typed UI: date fields become
dateboxes, enums become selects, foreign keys become entity pickers.
import { zeyosTable, zeyosForm } from '/assets/zx-zeyos.esm.js';
const list = zeyosTable(client, 'transactions', {
fields: ['transactionnum', 'account', 'date', 'netamount', 'status'],
pageSize: 25,
sort: { id: 'date', dir: 'desc' },
onRowClick: (row) => openEditor(row.ID)
});
await list.load({ search: '', filters: {} });
document.querySelector('#invoices').append(list.table);
The binding ships as a separate dist/zx-zeyos.esm.js bundle and is never
imported by src/index.js, so the core stays dependency-free. Query rules worth
remembering: filters is plural, dates are Unix seconds, and
visibility: 0 is the default scope.
For in-app remotecall-style requests that are not part of the OpenAPI surface,
Zx ships a minimal Http / zeyosService helper. For anything that
maps to a ZeyOS resource, prefer @zeyos/client.
The ZeyOS invoices layout is the reference integration: a
complete invoices screen generated from the real transactions and
accounts schemas, with server-side filtering, sorting, and paging.
Each interactive component is built to a specific APG pattern rather than having ARIA
sprinkled on afterwards: Select is an editable combobox,
MenuButton is a menu button, Tabbox is a tab list,
Dialog is a modal dialog on top of native <dialog>.
The pattern brings its full keyboard map with it.
Components express state through ARIA attributes and data-state — never
through state classes — and style themselves from those same attributes
([aria-expanded="true"], [aria-selected="true"],
[data-state="open"]). What a screen reader announces and what you see are
driven by one source of truth.
Focus styling uses :focus-visible with the --zx-focus-ring token,
so keyboard users get a ring and mouse users do not. Overlays trap focus with
focusTrap() and restore it to the trigger on close. Roving tabindex and
typeahead are available as helpers for custom widgets.
All non-essential animation sits behind
@media (prefers-reduced-motion: no-preference), and the base stylesheet
collapses transitions entirely when a reduced-motion preference is set.
Both stock themes are checked against WCAG AA for body text, muted text, status colours, and
text on accent fills. If you define a product theme, re-check the accent pair —
--zx-color-accent against --zx-color-on-accent — because that is
the one that usually slips.