Markup
JSX, compiled. Your components run once at build time and are gone from the shipped app — what survives is a node tree and a style table.
There is no @jsxImportSource pragma to write: jsxImportSource: "dziry" is set once
in tsconfig.json, and Bun's transpiler reads it too.
cn
doneconst isBig = signal(false);
<div className={cn("box", "rounded", { active: isBig })} />;
Builds a class list the compiler can read. Unlike clsx, it does not return a
string — it cannot. By the time a conditional class is a string, the connection to
the signal driving it is gone, and the compiler needs that connection to resolve the
class both ways at build time.
The result is that toggling a conditional class costs a few integer writes into the style table. There is no selector matching and no string comparison at run time, and nothing at all per frame.
Accepts strings, false, null, undefined, and objects. A literal boolean is
resolved immediately — cn({ big: true }) is just "big", with no runtime mechanism.
:::danger This is the one to get right
// Wrong — evaluates a signal object at build time and freezes.
<div className={"box " + (isBig ? "big" : "")} />
// Right.
<div className={cn("box", { big: isBig })} />
The first compiles cleanly and never updates. :::
Props
Both class and className are accepted. className is the JSX convention.
const draft = signal("");
const onSave = () => {};
<div
id="save-row"
className="flex gap-2"
style={{ padding: 8, fontWeight: 600 }}
onClick={onSave}
bind:value={draft}
/>;
onClick
A module-level exported function, because the generated artifact imports it by name.
Inside a list item it receives that row's item and index, since one compiled handler serves every row:
export function toggleDone(item: Todo): void {
todos.set((ts) => ts.map((t) => (t.id === item.id ? { ...t, done: !t.done } : t)));
}
style
doneEither CSS text or an object of camelCased properties:
<div style="color: red; padding: 8px" />;
<div style={{ color: "red", padding: 8, fontWeight: 600 }} />;
A number means pixels, except for the genuinely unitless properties — fontWeight,
flexGrow, flexShrink, flex, aspectRatio, gridColumn, gridRow, zIndex,
opacity, lineClamp. Same rule React uses, chosen because it is the one people
already have.
Inline styles are applied after the cascade and beat every selector, matching browser precedence. Both forms resolve at build time and cost the runtime nothing.
A value that is not static is a build error rather than a silent drop, because there is no node left to attach it to once the compiler is gone. Use a conditional class instead.
bind:value
done — for text-entry fields, and it is two-way — typing writes the signal, and a signal write repaints the field (so a loader can seed an edit form). The display half is a text binding jsx() inserts as the field's child; a launch-ordering gap that swallowed writes from an initial route's sync loader was fixed 2026-08-21 and is pinned by a test. This row previously said "append + backspace" — caret, selection, word-select and Home/End all landed with A5. Still missing: clipboard and IME (A5's remainder)const draft = signal("");
<input type="text" className="rounded bg-zinc-800 px-3 py-2" bind:value={draft} />;
A string signal the element edits. Click to focus, then typing appends and Backspace deletes. The value renders automatically when the element has no children of its own.
The colon is real syntax, not a naming convention. TypeScript parses a namespaced
JSX attribute and lowers it to a quoted key, then typechecks that key against the
element's props — so a misspelling is an error that names the property it meant, and
Bun's transform emits the identical key. bind: is a namespace rather than a prefix
because two-way is a different kind of prop: every other prop flows one way into the
build artifact, while these are the only place the engine writes back into your state.
bind:checked and bind:group join it when checkbox binding lands.
A bound value is always a string, including for number and range — the same
rule the DOM follows, where value is a string and valueAsNumber is separate. Write
derived(() => Number(volume)) if you want a number.
Inside a map() row it takes the row's own property — bind:value={job.title} — because a
row has no signal to name: its state is the item, in the one array the author holds. The list
callback runs against a recording proxy, so that is a path at build time, the same way
{job.title} already renders one. Typing replaces the item and the array, so an ordinary
signal.set publishes it. That is what makes a repeating form row work — see
field.
:::warning Partial
Append and backspace only. No caret, no selection, no IME, no clipboard. <input>
renders as a styled box with no widget appearance of its own, and clicking a <label>
does not yet focus the field it names.
:::
form
doneexport function save(data: { email: string; age: number | undefined; terms: boolean }) {
console.log(data.email, data.age, data.terms);
}
export const SignUp = () => (
<form onSubmit={save}>
<input name="email" />
<input name="age" type="number" />
<input name="terms" type="checkbox" />
<button>Sign up</button>
</form>
);
A <form> collects the controls in its subtree by name — the way a browser does — and hands
onSubmit the result. Enter in a field submits it, and so does a click on its submit button.
You do not declare state for the fields. A named field with no bind:value gets a cell the
compiler declares inside the generated artifact, seeded from its value, checked or selected
attribute. Nothing outside the artifact can name that cell; the payload is how you read it. A field
that does carry bind:value keeps your signal, so what is rendered and what is submitted cannot
drift apart.
The payload is typed by what each control is, because the compiler knows:
| markup | what you get |
|---|---|
<input name="x">, <textarea name="x"> | string |
<input name="x" type="number"> | number | undefined — never NaN |
<input name="x" type="checkbox"> | boolean |
<input name="x" type="radio"> × n | string | undefined — the checked one's value |
<select name="x"> | string |
<select name="x" multiple>, or two controls sharing a name | string[] |
Which controls are in the payload is the browser's rule, measured rather than recalled: a control
with no name is left out, so is a disabled one — including one disabled by an enclosing
<fieldset disabled> — an unticked checkbox contributes nothing, and an <option> with no value
submits its trimmed text.
field
done<form onSubmit={save}>
<div field="name">
<input />
</div>
<div field="position">
<input name="x" />
<input name="y" />
</div>
<div field="address">
<div field="city"><input /></div>
</div>
</form>;
gives { name: string, position: { x, y }, address: { city } }.
field on anything that wraps a control names a group, and the wrapper chain is the path.
A wrapper holding one bare control is that field; named controls inside it become its
properties; wrappers nest; an element without field is transparent, so a layout div nests
nothing. A path claimed as both a value and a group — a wrapper holding a bare control and a
named one — is a build error, because either answer would silently drop a field.
No browser does this. name="user[email]" is the literal string key "user[email]" in
FormData, and the bracket convention belongs to server-side parsers, each with its own
dialect. dziry nests by structure because a compiler can see structure: nothing is parsed at
run time, and there is no dialect to pick.
Radios are the exception. A radio set has to share a name — that is what makes it a set —
so inside a wrapper the name groups it and the wrapper names it:
<div field="plan">
<input type="radio" name="plan" value="free" />
<input type="radio" name="plan" value="pro" />
</div>;
gives one key, plan, and not plan.plan. Same reason its shape is a single value: many
elements, one answer. Use one radio group per wrapper — two groups under one wrapper would both
claim its key, and the build says so.
A wrapper holding a map() is an array field, and its value is the array the rows came
from:
<div field="experience">
{jobs.map((job) => <input bind:value={job.title} />, { key: (job) => job.id })}
</div>;
gives { experience: Job[] }. It is the one field whose state the compiler does not declare:
a row's controls are capacity interchangeable replicas of one template, so there is nothing
stable to hang a per-row cell on, while the array has an entry and a key per row already. So
the array is the state — bind:value={job.title} writes back into it, and adding a row is an
ordinary signal.set. The entry is the item as authored, key property included.
See the Forms guide for the whole feature end to end.
errorClassName
done<div field="email" errorClassName="group/error">
<input className="error:border-red-500" />
<span error className="hidden error:block" />
</div>;
@custom-variant error (.group\/error &);
The wrapper wears those classes while its field has a validation error — where "its" means any
issue whose path starts with the wrapper's, so a position wrapper lights up for an issue at
position.x. It compiles to the same style-table writes a conditional class does, so the
input's border and the message's visibility both come from a class on the wrapper and none of it
is JavaScript. Twenty fields can share the class string and stay independent.
Write the Tailwind variant in the prefix form shown above. Tailwind's default form emits
:is(:where(.group\/error) *), and the * inside :is() is not a selector dziry parses.
<span error /> marks where the message goes; its text becomes a binding to a cell the compiler
declares, and anything you write inside it is placeholder prose that never ships.
A marker can name a field — <span error="city" /> inside field="address" shows the issue
at address.city, relative to the wrapper exactly as name is. Each marker shows the first
issue under its own path that no more specific marker would show, so a group's complaints divide
between them and nothing appears twice. A name matching no field is a build warning.
Inside a map() row it is that row's message, matched by data position, and the wrapper's own
message narrows to issues at its own path so the two do not say the same thing twice.
The control is styled with :invalid rather than with this class — a predicate, resolved
per node, which is what lets one list row be red and the next one not: replicas share a style
row but not a control row. See the Forms guide.
validateOn
done<form validateOn="change" validate={Login} onSubmit={save}>;
"submit" (the default), "change", or "blur". Two things are behaviour rather than options,
because neither is a preference:
- after a failed submit, the form re-validates as its fields change, so an error clears the moment you fix it;
- before any submit, a field shows an error only once its value has moved off the one it was compiled with — so a pristine form does not turn red as you tab through it.
There is no touched or dirty to manage. The first is what validateOn is for, and the
second costs nothing to derive: the initial value is a constant the compiler wrote down.
validate
doneimport * as z from "zod";
import { Schema } from "effect";
export const Login = z.object({ email: z.email(), age: z.number().min(18) });
export const Same = Schema.Struct({ email: Schema.String });
export const showErrors = (issues: { path: (string | number)[]; message: string }[]) =>
console.log(issues);
<form onSubmit={save} validate={Login} onInvalid={showErrors}>
<input name="email" />
<input name="age" type="number" />
<button>Sign up</button>
</form>;
validate checks the payload before onSubmit sees it. It accepts any Standard Schema —
which Zod 4, Valibot and ArkType are — any Effect schema, or a plain function returning issues.
dziry depends on none of them: the standard ones are used through their ~standard property, and
an Effect schema is converted with Effect's own Schema.standardSchemaV1 behind an import that
only happens if you actually pass one.
A schema also narrows what onSubmit receives — it gets the schema's output, so a
z.coerce.date() field arrives as a Date rather than as the string that was typed. When
validation fails, onSubmit does not run and onInvalid gets the issues, normalised to
{ path, message }[] whichever library produced them.
:::warning Partial
A named control inside a map() row is not collected — a name in a template would be the
same string in every row, so rows reach the payload as an array field instead. There are also no
file inputs, and a named submit button does not add its own entry.
:::
bind
const count = signal(0);
const text = bind(count);
Wraps a signal as a dynamic text node. You rarely need it — interpolating a signal does this for you. It exists for cases where the value must be built as data rather than written in markup.
Fragment
<>
<div />
<div />
</>
Groups children without introducing a node, and is spliced away during flattening. JSX desugars to it automatically; the export exists so it can be named directly.
Show
doneimport { Show, signal } from "dziry";
const open = signal(false);
<Show when={open} fallback={<div>nothing selected</div>}>
<div>the details</div>
</Show>;
Conditional rendering, compiled. Both trees are built into the window as siblings;
the condition's truthiness picks one with a hidden-byte write — the same switch a
navigation makes. Nothing mounts or unmounts, so flipping it costs no layout beyond
the reflow itself.
when takes a signal, a computed, or an expression over them — when={count > 5}
works, and truthiness is JavaScript's, so when={items.length} closes on an empty
array. A constant condition is resolved at build time: the winning tree is
compiled, the losing tree never becomes nodes.
fallback is optional. Without one, a closed Show is nothing, and the layout
collapses as if the subtree were display: none — because it is.
Two shapes are refused at build time, each because the switch hides nodes: bare
text directly inside the boundary (it would stay visible in both states), and a bare
<Show> or <Suspense> directly inside another boundary (wrap it in an element).
Lists
done{
todos.map((t) => <Row title={t.title} mark={t.mark} />, { key: (t) => t.id });
}
key is required. The list compiles to a template plus an arena of item slots:
the callback runs once at build time with a recording proxy, so t.title becomes a
path the runtime reads out of the array rather than a value baked into the output.
That is also the constraint. Because the callback runs once with a proxy rather than once per item with real data, an item template cannot contain a conditional:
// Wrong — the proxy is always truthy, so this always takes the first branch.
todos.map((t) => <div>{t.done ? "x" : " "}</div>, { key: (t) => t.id });
Anything conditional per row has to be data. Compute it where real values exist:
export const view = computed(() =>
todos.map((t) => ({ ...t, mark: t.done ? "[x]" : "[ ]" })),
);
Note that this inner .map has no key, so it is an ordinary build-time map over
real values — which is exactly what a computed body needs.
To deliberately snapshot rather than build a live list, copy the array first:
[...todos].map(...) takes the static path.
Types
import type { Child, ClassArg, ClassSpec, Component, Props, StyleObject } from "dziry";
Props is the base prop type; extend it for your own components:
function Row({ title, mark }: Props & { title: string; mark: string }) {
return (
<div className="row">
{mark} {title}
</div>
);
}
ClassSpec is what cn returns, ClassArg what it accepts, StyleObject the object
form of style, and Child the type of children.