Zero-dependency reactive forms: schema or enhancement, sync + async validation, loading-state submission, server error mapping, and a honeypot bots fall into. Every card below is live.
<script src="https://cdn.jsdelivr.net/gh/vanilla-ui-kit/components/form/form.js"></script>
Async username check (try taken), password toggle, fake
2s submit with loading state and success.
new Form('#signup', {
fields: [
{ name: 'username', required: true,
validate: v => checkTaken(v) }, // async!
{ name: 'email', type: 'email', required: true },
{ name: 'password', type: 'password',
required: true, minlength: 8 }
],
onSubmit: values => api.signup(values)
})
A plain HTML <form>, adopted: its
required/type attributes seed the validators.
Submitting simulates a server 422 whose
{errors:{…}} body is mapped onto the fields.
new Form(formEl, {
onSubmit: function () {
// server says 422 {errors:{email:…}}
return Promise.reject({ errors: {
email: 'This address is blocked',
message: 'Please keep it under 500 chars'
}})
}
})
An off-viewport decoy input (not display:none —
bots check for that) plus a time gate. Fill it "like a bot" and the form
pretends to succeed while onSpam logs the catch. Humans
never see it: it sits at left:-9999px, out of the tab
order, hidden from screen readers.
new Form('#el', {
honeypot: true, // default
minFillTime: 1500, // ms time gate
onSpam: values => log(values)
})
// client honeypots stop dumb bots only —
// ALWAYS validate on the server too (_hp_t helps)
Single-boolean checkbox, styled switch, fieldset/legend radio group — all reactive. Watch the live snapshot update.
form.watch(function (values) {
render(values) // any change
})
This page loads ../select/select.js and
../datepicker/datepicker.js, so the select and
date fields below auto-upgrade to the family widgets. The
phone field stays a native <input type=tel>
because no PhoneInput is loaded — graceful degradation, no code changes.
<script src="../select/select.js"></script>
<script src="../datepicker/datepicker.js"></script>
<script src="./form.js"></script>
// fields: [{name:'plan', type:'select', …},
// {name:'start', type:'date'},
// {name:'phone', type:'phone'}]
setValues, setError, reset,
disable — the instance is the single source of truth.
form.setValues({ city: 'Berlin' })
form.setError('city', 'Not deliverable')
form.isDirty() // true
Complete reference for Form v1.0.0, matching the source of
form.js. Everything below also applies via the family bundle
(dist/vanilla-ui-kit.js) and npm (vanilla-ui-kit/form).
new Form(target, options?) // target: CSS selector string or Element
Form.create(target, options?) // identical to new Form(target, options)
The constructor picks one of two modes based on the target element:
Schema mode — the target is any container (not a
<form>) and you pass a fields array. The whole form is built
for you: labels, hints, per-field error regions, submit button, error banner, status live
region, and honeypot.
var form = new Form('#signup', {
fields: [
{ name: 'email', type: 'email', label: 'Email', required: true },
{ name: 'password', type: 'password', label: 'Password', required: true, minlength: 8 }
],
successMessage: 'Account created',
onSubmit: function (values) { return api.signup(values) } // may return a Promise
})
Enhance mode — the target is an existing <form>
element. Its named controls become the reactive state; its validation attributes
(required, pattern, min, max,
minlength, maxlength, type="email"|"url"|"number")
seed the validators; honeypot and submission handling are added. If no
onSubmit/action option is given, the form's own
action/method attributes are adopted and submitted via fetch as
FormData (encoding: 'form'). destroy() restores the
original form untouched.
var form = new Form(document.querySelector('form.contact'))
Behavior notes from source: passing a selector that matches nothing
throws Error('Form: target element not found: …'). Constructing on an element
that already has an instance destroys the old instance first. SSR-safe: with no DOM, or with
new Form(null, …), the constructor returns an inert no-op handle with the full
method surface. Native constraint popups are suppressed (novalidate) in favor of
the kit's own rendering.
Second constructor argument. Every key of Form.defaults is listed; change any
default once, page-wide, via Form.defaults.
| Name | Type | Default | Description |
|---|---|---|---|
fields | array | null |
Schema mode: array of field specs (see table below). Specs without a name are skipped. Ignored in enhance mode. |
validateOn | string | 'blur' |
'blur' validates a field when it loses focus; 'change' validates live on every change; 'submit' only at submit time. In every mode, once a field has an error it re-validates live on each input so the message clears the moment the value is fixed. Submit always runs a full pass. |
onSubmit | function | null |
fn(values, form), may return a Promise. Resolve = success; reject/throw = error handling. See Events & callbacks. |
onError | function | null |
fn(err, form), fired after a rejected submit has been rendered (banner or field errors). |
onChange | function | null |
fn(values, form) on any value change. |
onSpam | function | null |
fn(values) when the honeypot decoy or the time gate trips. The bot still sees the normal success UX. |
action | string | null |
URL — the form fetches for you (see encoding). In enhance mode, a form action attribute is adopted automatically when this option is not given. Non-2xx responses become errors; 2xx resolves with the parsed JSON body. Falls back to XMLHttpRequest when fetch is missing. For GET/HEAD, values are serialized into the query string instead of a body. |
method | string | null |
HTTP method for action submits. Defaults to the form's method attribute (enhance mode), else POST. |
encoding | string | 'json' |
'json' sends a JSON body with Content-Type: application/json; 'form' sends FormData (booleans append '1' when true, are omitted when false). When enhance mode adopts an action attribute, encoding defaults to 'form'. |
headers | object | null |
Extra request headers for action submits, e.g. {'X-CSRF-Token': token}. Requests are sent with credentials: 'same-origin'. |
honeypot | boolean | true |
Adds the off-viewport decoy field plus the minimum-fill-time gate, and a hidden _hp_t input that carries the elapsed milliseconds to your server on action submits. false disables both traps. |
honeypotName | string | null |
Decoy field name. When null, a realistic name is picked from website_url / company_website / homepage_url / contact_website (suffixed if it would shadow a real field). |
minFillTime | number | 1500 |
Milliseconds after render; faster submits are treated as bots (silent fake success + onSpam). |
resetOnSuccess | boolean | false |
Reset the form to its initial values after a successful submit. |
successMessage | string | null |
Rendered in the polite status live region on success — or shown as Toast.success(…) when the family Toast is on the page. |
submitLabel | string | false | null |
Schema-mode submit button text; null uses labels.submit ('Submit'); false renders no button at all. |
theme | string | 'auto' |
'auto' follows the page theme live; pin with 'light' or 'dark'. See Theming. |
styles | boolean | true |
false = headless: no CSS is ever injected; style the stable .vfm-* class hooks yourself. The honeypot stays hidden via inline styles even headless. |
labels | object | see below | Every user-facing string; merged shallowly over the defaults, so you can override single keys (also the localization hook). Keys in the next table. |
| Key | Default | Used for |
|---|---|---|
required | 'This field is required' | Empty required field |
email | 'Enter a valid email address' | Email format |
url | 'Enter a valid URL' | URL format |
number | 'Enter a number' | Non-numeric value in a number field |
min | 'Must be at least {min}' | Number below min; {min} is interpolated |
max | 'Must be at most {max}' | Number above max; {max} is interpolated |
minlength | 'Must be at least {min} characters' | Too-short value; {min} is interpolated |
maxlength | 'Must be at most {max} characters' | Too-long value; {max} is interpolated |
pattern | 'Does not match the expected format' | Pattern mismatch |
phone | 'Enter a valid phone number' | Phone field whose PhoneInput upgrade reports valid === false |
submit | 'Submit' | Default submit button text (schema mode) |
submitError | 'Something went wrong. Please try again.' | Generic form-level error banner |
showPassword | 'Show password' | aria-label of the password toggle |
hidePassword | 'Hide password' | aria-label of the password toggle when visible |
Each entry of fields is a plain object. Labels, hints and messages are
rendered with textContent; user values are never rendered as HTML.
| Property | Type | Default | Description |
|---|---|---|---|
name | string | — (required) | Control name and the key in form.values. Specs without a name are skipped. |
type | string | 'text' |
One of text, email, password, number, url, tel, textarea, checkbox, switch, radio, select, date, phone, hidden (see field types below). |
label | string | the field's name |
Visible <label> (or <legend> for radio groups). Required fields get a decorative * (aria-hidden). |
placeholder | string | — | Input placeholder. On select, renders an empty-value first option with this text. |
hint | string | — | Help text under the label, wired via aria-describedby. Plain text unless html: true. |
value | any | — | Initial value. Boolean for checkbox/switch; matched against option values for radio/select. |
required | boolean | false |
Adds the required validator and the native required attribute. |
disabled | boolean | false |
Renders the control disabled (per-option for radio via option objects). |
options | array | — | For radio/select: ['a', 'b'] shorthand or [{value, label, disabled}] objects. |
min / max | number | string | — | Number range validator + native attributes; also forwarded to a DatePicker upgrade as its min/max. |
minlength / maxlength | number | — | Length validator + native attributes. |
pattern | string | RegExp | — | Pattern validator. A string is compiled as ^(?:pattern)$ (and also set as the native attribute); a RegExp is used as-is (no native attribute). A broken pattern is ignored rather than breaking the form. |
validate | function | function[] | — | Custom validator(s): fn(value, values) returning null when valid, a message string when invalid — or a Promise of either for async checks. Stale async verdicts are discarded when the value changes mid-flight. A thrown error's message becomes the error text. |
html | boolean | false |
Per-field opt-in to render the hint as trusted HTML markup. Applies to the hint only. |
rows | number | 3 |
textarea only: initial rows. |
autoGrow | boolean | false |
textarea only: grows with content as the user types. |
| type | Renders | Notes |
|---|---|---|
text / email / password / number / url / tel | <input> |
password gets a show/hide toggle button; email/url/number get format validators. |
textarea | <textarea> | rows, autoGrow: true for grow-with-content. |
checkbox | checkbox | Single boolean value. |
switch | styled checkbox | Same boolean semantics, toggle look. |
radio | radio group | options array; rendered in a <fieldset> with <legend>. |
select | <select> | options array; family-upgrades to Select when window.Select exists. |
date | <input type=date> | Family-upgrades to DatePicker (rendered as a text input when DatePicker is present at build time). |
phone | <input type=tel> | Family-upgrades to PhoneInput; its .valid flag feeds a phone-format validator. |
hidden | <input type=hidden> | Value included in submissions, no UI, no label/error region. |
Family upgrades are never required — without Select/DatePicker/PhoneInput
(standalone globals or via window.VC.components) you keep fully working native
fields. A failing upgrade degrades to the native control. Enhance mode never upgrades
adopted controls.
| Method | Returns | Description |
|---|---|---|
submit() | Promise<boolean> |
Programmatic submit: honeypot check, full validation, then onSubmit/action. Resolves true on success, false on validation failure or submit error. No-op (resolves false) while already submitting or disabled. |
validate(config?) | Promise<boolean> |
Full validation pass over every field, async validators included; resolves true when clean. {silent: true} skips rendering (no messages, no aria-invalid). |
isValid() | boolean |
Synchronous verdict using sync validators only, no rendering; async validators still in flight count as valid. |
getValue(name) | any | Current value of one field (undefined for unknown names). Booleans for checkbox/switch, arrays for multi-selects and same-name checkbox groups. |
setValue(name, value, config?) | this |
Write a field's value and run the normal change pipeline (watchers, onChange, live re-validation of erroring fields). {silent: true} updates the value and dirty state without notifying. |
setValues(obj, config?) | this |
setValue for every key of obj; same optional {silent: true}. |
setError(name, message) | this |
Set (or clear, with a falsy message) a field's error manually — e.g. mapping your own server responses. |
clearErrors() | this |
Clear all field errors and the form-level banner. |
isDirty() | boolean |
true when any field differs from its initial value. |
watch(name?, fn) | function | watch('email', fn(value, meta)) for one field, or watch(fn(values, changedName, meta)) for any change; meta is {dirty, touched, error}. Returns an unsubscribe function. |
reset() | this |
Back to initial values; errors, banner, status, dirty/touched flags, and in-flight async verdicts all cleared. |
enable() / disable() | this |
Enable/disable every control (family upgrades included) and the submit button; disable() adds is-disabled on the form and blocks submit(). enable() restores each control's original disabled state. |
destroy() | this |
Tear down: schema mode removes the built DOM; enhance mode restores the original form (listeners, classes, attributes, and added nodes removed). Family upgrades are destroyed too. |
| Property | Type | Description |
|---|---|---|
values | object (getter) | Plain-object snapshot of all field values — this is the "getValues/serialize" of the API. Fresh copy on each read. |
errors | object (getter) | {name: message} snapshot of current errors. |
el | Element | The target element you passed (container in schema mode, the <form> in enhance mode). |
form | HTMLFormElement | The live <form> element (null after destroy()). |
opts | object | The resolved options (defaults merged with what you passed). |
Per-field meta is tracked automatically: dirty compares
against the initial value, touched is set on first blur. On an inert SSR handle
all of these methods exist and are harmless no-ops.
| Static | Description |
|---|---|
Form.create(target, options?) | Identical to new Form(target, options). |
Form.get(target) | Returns the live instance attached to an element (or selector), or null. |
Form.autoInit(root?) | Initializes every <form data-vfm> under root (default: document) that has no instance yet; returns the array of created instances. Runs once automatically on DOMContentLoaded. One bad form logs an error without aborting the rest. |
Form.validators | The pure built-in validators (table below) — no DOM needed, usable in Node to share rules with your server. |
Form.defaults | The live defaults object — change any default once, page-wide (see Options). |
Form.version | Version string, '1.0.0'. |
Form.salt | CSS salt namespace token, default 'vc1'. Set your own token or false (no salting) before the first form is created. |
Form.css | The full embedded stylesheet as a string, rendered with the current salt — a starting point for headless styling (also shipped as dist/form.css). |
Form.rootClass | 'vfm' — the root class stamped on every form. |
Form.themeVars / Form.varScopes | Convergence metadata for the VC core: the theme variables it may bridge (accent, radius, font) and the scopes where variables are defined ('.vfm', '.vfm[data-theme=dark]'). |
Contract: each returns null when valid, a message string when invalid. Format
validators pass on empty values — pair them with required. The optional
labels argument supplies the message strings and defaults to
Form.defaults.labels.
| Validator | Signature | Behavior |
|---|---|---|
required | required(value, values?, labels?) |
Invalid when the value is empty: null/undefined, false, an empty array, or a whitespace-only string. |
email | email(value, values?, labels?) |
Pragmatic email check (something@something.tld, no spaces). |
url | url(value, values?, labels?) |
Requires an absolute http:// or https:// URL. |
number | number(value, min, max, labels?) |
Invalid when not numeric, below min, or above max (either bound may be null/omitted). |
length | length(value, min, max, labels?) |
String-length bounds; either bound may be null/omitted. |
pattern | pattern(value, pattern, labels?) |
Tests against a RegExp, or a string compiled as ^(?:pattern)$. An invalid pattern string returns null (never breaks the form). |
Form.validators.email('a@b.co') // null (valid)
Form.validators.email('nope') // 'Enter a valid email address'
Form.validators.number('5', 1, 10) // null
All eventing is via option callbacks and watch() — the component dispatches
no custom DOM events (it listens to the native submit,
input, change, and focusout events internally).
| Callback | Payload | Fires |
|---|---|---|
onSubmit(values, form) | values: plain-object snapshot; form: the instance |
After a clean validation pass on submit. May return a Promise: while pending, the submit button is disabled with a spinner (is-loading, aria-busy) and repeat submits are ignored. Resolve = success (resetOnSuccess, successMessage); reject/throw = error contract below. |
onError(err, form) | The rejection value, unchanged | After a rejected submit has been rendered. Error contract: an Error shows its message in the focused role="alert" banner; a plain object {field: message} or {errors: {field: message}} (e.g. a server 422 JSON body) is distributed to matching fields with the first one focused (unknown field names fall back to the generic banner); anything else shows labels.submitError. |
onChange(values, form) | Fresh values snapshot; the instance | On any value change — typing, family upgrades, or non-silent setValue/setValues. |
onSpam(values) | Values snapshot at the moment the trap fired | When the honeypot decoy has a value or the submit beat minFillTime. The form then fakes a normal success. |
watch('name', fn) | fn(value, meta), meta = {dirty, touched, error} |
On every change of that one field. Returns an unsubscribe function. |
watch(fn) | fn(values, changedName, meta) |
On any change of any field. Returns an unsubscribe function. |
field validate | fn(value, values) → null | message | Promise |
During validation of that field (see the field-spec table). |
Any <form data-vfm> is enhanced automatically on
DOMContentLoaded (immediately if the script loads later) — zero JS required.
For markup added after page load, call Form.autoInit(rootEl). Options map to
attributes:
| Attribute | Option | Notes |
|---|---|---|
data-vfm | — | Marker: opts the form into auto-init (enhance mode). |
data-vfm-validate-on | validateOn | blur | change | submit |
data-vfm-encoding | encoding | json | form |
data-vfm-action | action | Overrides the form's action attribute. |
data-vfm-method | method | Overrides the form's method attribute. |
data-vfm-success | successMessage | Success text (Toast if present). |
data-vfm-theme | theme | auto | light | dark |
data-vfm-honeypot-name | honeypotName | Decoy field name. |
data-vfm-min-fill-time | minFillTime | Parsed as a number (ms). |
data-vfm-honeypot | honeypot | Boolean: "false" or "0" = off, anything else = on. |
data-vfm-styles | styles | Boolean, same parsing; "false" = headless. |
data-vfm-reset | resetOnSuccess | Boolean, same parsing. |
<form data-vfm data-vfm-success="Thanks!" data-vfm-reset action="/api/contact" method="post">
<label for="e">Email</label>
<input id="e" name="email" type="email" required>
<button>Send</button>
</form>
Auto light/dark with the family's resolution order: <html data-theme> /
data-bs-theme → .dark/.light class on
<html> → prefers-color-scheme, re-resolved live (media-query
listener + MutationObserver). Pin per instance with theme: 'light'
or 'dark'. All colors are CSS custom properties on .vfm:
| Property | Light default | Dark default | Purpose |
|---|---|---|---|
--vfm-accent | #5b5bd6 | #7b7bea | Focus rings, submit button, checks, switches |
--vfm-danger | #e5484d | #f2555a | Error text, invalid borders, banner |
--vfm-success | #1f9d5b | #4ccb8f | Success status text |
--vfm-bg | #ffffff | #1b1d24 | Input background |
--vfm-surface | #f2f2f5 | #272a33 | Disabled inputs, hover surfaces |
--vfm-text | #1c1d21 | #e9eaf0 | Text color |
--vfm-muted | #72747e | #989aa6 | Hints, placeholders, secondary icons |
--vfm-faint | #e7e7ec | #31343f | Borders, switch track |
--vfm-accent-soft | rgba(91,91,214,.13) | inherits light rule | Focus ring glow; derived via color-mix (14% of accent) where supported, so it follows a custom accent automatically |
--vfm-danger-soft | rgba(229,72,77,.12) | inherits light rule | Invalid focus ring, banner background; color-mix (13% of danger) where supported |
--vfm-shadow | 0 10px 28px rgba(24,25,32,.14), 0 2px 8px rgba(24,25,32,.08) | 0 10px 28px rgba(0,0,0,.5), 0 2px 8px rgba(0,0,0,.35) | Elevation shadow token |
--vfm-radius | 12px | same | Banner corner radius token |
--vfm-font | system-ui, … | same | Form font stack |
.vfm { --vfm-accent: #b45309; } /* page-wide */
.vfm[data-theme="dark"] { --vfm-accent: …; } /* per theme */
CSS isolation: forms render as
class="vfm vc1" and all structural rules ship salted
(.vfm.vc1 .vfm-input { … }) so host-page design systems can't override the
fields — while the --vfm-* variable definitions are deliberately unsalted so
your overrides keep working. Custom token: Form.salt = 'acme' before the first
form; disable with Form.salt = false. With the VC core loaded,
VC.config({ accent: '#b45309' }) themes forms and every family component at
once.
Headless: styles: false (or
Form.defaults.styles = false page-wide) never injects CSS while keeping the full
behavior — reactive state, validation, submission, honeypot, ARIA — and stable class hooks:
.vfm-field, .vfm-label, .vfm-hint,
.vfm-input, .vfm-error, .vfm-check,
.vfm-switch, .vfm-fieldset, .vfm-option,
.vfm-inputwrap, .vfm-eye, .vfm-banner,
.vfm-status, .vfm-actions, .vfm-submit, plus state
classes is-invalid, is-loading, is-disabled. Start
from Form.css (string) or dist/form.css (file). The honeypot stays
hidden headless too (inline off-viewport styles).
All controls are native form elements, so the keyboard model is the platform's own:
| Key | Where | Action |
|---|---|---|
Tab / Shift+Tab | anywhere | Move between fields; the honeypot is excluded from the tab order (tabindex="-1"). |
Enter | text-style inputs, submit button | Submit the form (runs the kit's validation; native popups are suppressed via novalidate). |
Space | checkbox / switch, radio, password toggle | Toggle the control / select the radio / show-hide the password. |
Arrow keys | radio group, select | Move selection within the group (native behavior; upgraded Select/DatePicker keep their own documented keyboard maps). |
<label for>/id pair; radio groups use <fieldset>/<legend>; the required marker * is aria-hidden.aria-describedby; failing fields get aria-invalid="true" (removed once fixed) and the is-invalid class.aria-live="polite" element, present up-front so screen readers announce message changes.role="alert" with tabindex="-1", and is focused when shown; success messages go to a polite live region (or a Toast when present).aria-busy="true" and is disabled; the spinner is decorative.<button> with aria-pressed and a show/hide aria-label (labels.showPassword / labels.hidePassword); it returns focus to the input.aria-hidden="true", off-viewport (not display:none), and out of the tab order — invisible to humans and assistive tech alike.:focus-visible outlines everywhere; prefers-reduced-motion disables all transitions and animations, spinner included.styles: false injects nothing — reactive state,
validation, submission, honeypot, ARIA and the stable .vfm-* class hooks all
remain; you bring your own CSS. The demos above have already injected the kit stylesheet
into this page, so a headless instance here would still match those rules — each demo
below therefore runs inside its own <iframe>, a genuinely clean
document.
Raw browser rendering, zero CSS — submit empty to see validation, focus management
and the aria-live error regions all still working.
new Form('#signup', {
styles: false, // no CSS injected
fields: [
{ name: 'email', type: 'email',
label: 'Email', required: true },
{ name: 'password', type: 'password',
required: true, minlength: 8 }
]
})
The same hooks — inputs, errors, radio group, switch, loading submit — mapped to a
SaaS onboarding look with Tailwind @apply. Loads
cdn.tailwindcss.com only when you ask.
.vfm-label { @apply mb-1.5 block text-[13px]
font-semibold text-slate-700; }
.vfm-req { @apply text-rose-500; }
.vfm-hint { @apply -mt-1 mb-1.5 text-xs text-slate-400; }
.vfm-input { @apply w-full rounded-lg border
border-slate-300 bg-white px-3.5 py-2.5 text-sm
shadow-sm outline-none transition; }
.vfm-input:focus { @apply border-slate-900 ring-4
ring-slate-900/10; }
.vfm-input.is-invalid { @apply border-rose-500 ring-4
ring-rose-500/10; }
.vfm-error { @apply mt-1.5 text-[13px] font-medium
text-rose-600; }
.vfm-eye { @apply absolute right-2 top-1/2 h-7 w-7
-translate-y-1/2 rounded-md text-slate-400; }
.vfm-check input { @apply h-4 w-4 accent-slate-900; }
.vfm-switch input { appearance: none; @apply relative h-5
w-9 rounded-full bg-slate-300 checked:bg-slate-900; }
.vfm-option { @apply mb-1.5 flex items-center gap-2; }
.vfm-banner { @apply mb-4 rounded-lg border border-rose-200
bg-rose-50 px-3.5 py-2.5 text-[13px] text-rose-700; }
.vfm-status { @apply mt-3 text-sm font-semibold
text-emerald-600; }
.vfm-submit { @apply w-full rounded-lg bg-slate-900 px-4
py-2.5 text-sm font-semibold text-white; }
.vfm-submit.is-loading { @apply text-transparent; }
.vfm-submit.is-loading::after { content: ''; @apply
absolute left-1/2 top-1/2 -ml-2 -mt-2 h-4 w-4
animate-spin rounded-full border-2 border-white/30
border-t-white; }