CORE
Forms
Two shapes, chosen per form. A submit-only form needs no bindings at all — the platform already collects it. A form whose UI reacts while the user types gets a signal per field. There is no v-model-style helper, deliberately: it would have to switch on element type, which is exactly the magic a vanilla author never writes.
Submit-only: FormData
Name the fields, read them on submit. Native required, type="email" and friends validate before your handler runs.
const Signup = () => html`
<form onsubmit=${async (e) => {
e.preventDefault();
const data = new FormData(e.target); // every field, by name — zero bindings
await fetch('/api/signup', { method: 'POST', body: data });
}}>
<input name="email" type="email" required /> <!-- native validation runs first -->
<select name="plan"><option>free</option><option>pro</option></select>
<button>Sign up</button>
</form>`;
Live: a signal per field
For validation while typing, a disabled submit button, a character counter, a live preview. One attribute binds the value in, one listener writes it out; value, checked and selected are written as properties, so form state keeps tracking the signal after the user has touched the control.
const email = signal('');
const agreed = signal(false);
const plan = signal('free');
const valid = computed(() => email.value.includes('@') && agreed.value);
html`
<input value="${email}" oninput=${(e) => (email.value = e.target.value)} />
<input type="checkbox" checked="${agreed}" onchange=${(e) => (agreed.value = e.target.checked)} />
<select value="${plan}" onchange=${(e) => (plan.value = e.target.value)}>
<option>free</option><option>pro</option>
</select>
<button disabled="${() => !valid.value}">Sign up</button>
<p>${() => email.value.length}/64</p>`
The four element shapes
Text: value + oninput. Checkbox: checked + onchange, reading e.target.checked. Select: value + onchange — the value is bound after the options exist, so it always finds its option. Radio: compare-and-set.
const size = signal('m');
const Radio = (v) => html`<label>
<input type="radio" name="size" value="${v}" checked="${() => size.value === v}" onchange=${() => (size.value = v)} /> ${v}
</label>`;
textarea
A raw-text element carries static content only, so bind a <textarea> through value="${text}", never through its children. The static content stays as the default; the property tracks the signal.
With the router
A <form method="post"> inside a routed page is intercepted by the Navigation API and handed to the page's action({ formData }); return redirect('/thanks') to move on. The same form works with JavaScript disabled.