Skip to content

Forms and Validation

The browser has a complete form model that most frontend stacks ignore.

<form>
<label>
Email
<input type="email" name="email" required />
</label>
<label>
Password
<input type="password" name="password" minlength="8" required />
</label>
<button>Sign in</button>
</form>

What you get without writing one line of JS:

  • The form will not submit if either field is empty.
  • The email field validates against an email regex.
  • The password field validates against minlength.
  • Invalid fields get the :invalid pseudo-class and can be styled.
  • The browser focuses the first invalid field on submit.
  • Screen readers announce validation errors.
  • event.target on submit is a FormData-shaped object you can new FormData(form) from.

When you want to customize, you have hooks: setCustomValidity() lets you add your own messages, validationMessage lets you read them, the invalid event lets you intercept default UI.

Custom elements can participate in <form> natively. Use ElementInternals and static formAssociated = true:

class MyEmailInput extends HTMLElement {
static formAssociated = true
#internals = this.attachInternals()
#input = document.createElement('input')
constructor() {
super()
this.attachShadow({ mode: 'open' }).append(this.#input)
this.#input.type = 'email'
this.#input.addEventListener('input', () => {
this.#internals.setFormValue(this.#input.value)
if (!this.#input.validity.valid) {
this.#internals.setValidity(this.#input.validity, this.#input.validationMessage, this.#input)
} else {
this.#internals.setValidity({})
}
})
}
get value() { return this.#input.value }
}
customElements.define('my-email', MyEmailInput)
<form>
<my-email name="email" required></my-email>
<button>Submit</button>
</form>

new FormData(form).get('email') returns the value. Validation flows. The element participates in :invalid. Screen readers see it as a form control.

This is the right way to build custom inputs. It’s a little verbose. <kit-field> wraps the verbosity for the common case (label + native input + error), and the door is open to ship form-associated custom inputs as the library grows.

<kit-field> keeps the input light DOM and adds:

  • Visible label, association via for/id.
  • Optional description with aria-describedby.
  • Optional error with role="alert" and aria-invalid="true".
  • Required handling with a visible asterisk and required propagated to the input.
<kit-field label="Email" description="We'll never share it." required>
<input type="email" name="email" />
</kit-field>

The input is real. It validates. new FormData(form) reads it. The label, description, and error are wired together for screen readers. Three lines, full accessibility.

You can keep using regular form submission, with full progressive enhancement, or you can intercept:

<form id="signup">...</form>
<script type="module">
document.getElementById('signup').addEventListener('submit', async (e) => {
e.preventDefault()
const data = Object.fromEntries(new FormData(e.target).entries())
await fetch('/api/signup', { method: 'POST', body: JSON.stringify(data) })
})
</script>

Or in a Kitsune app, dispatch a command from the form’s submit:

form.addEventListener('submit', (e) => {
e.preventDefault()
shell.runtime.command({
type: 'note.save',
payload: Object.fromEntries(new FormData(e.target).entries()),
})
})

The submit becomes a runtime command. A persistence module handles it. The form has zero knowledge of where the data goes.

Next: CSS, Layers, and Container Queries →