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.

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

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

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