Vanilla UI Kit Form v1.0.0 · toast → select →

Forms, one file.

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>

Schema mode — signup

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)
})

Enhance mode — contact

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'
    }})
  }
})

Honeypot — what bots see

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.

spam log: (empty)
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)

Checks, switches, radios

Single-boolean checkbox, styled switch, fieldset/legend radio group — all reactive. Watch the live snapshot update.

form.watch(function (values) {
  render(values)   // any change
})

Family upgrades

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'}]

Programmatic API

setValues, setError, reset, disable — the instance is the single source of truth.

form.setValues({ city: 'Berlin' })
form.setError('city', 'Not deliverable')
form.isDirty()  // true

API reference

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

Constructor & usage

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.

Options

Second constructor argument. Every key of Form.defaults is listed; change any default once, page-wide, via Form.defaults.

NameTypeDefaultDescription
fieldsarraynull Schema mode: array of field specs (see table below). Specs without a name are skipped. Ignored in enhance mode.
validateOnstring'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.
onSubmitfunctionnull fn(values, form), may return a Promise. Resolve = success; reject/throw = error handling. See Events & callbacks.
onErrorfunctionnull fn(err, form), fired after a rejected submit has been rendered (banner or field errors).
onChangefunctionnull fn(values, form) on any value change.
onSpamfunctionnull fn(values) when the honeypot decoy or the time gate trips. The bot still sees the normal success UX.
actionstringnull 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.
methodstringnull HTTP method for action submits. Defaults to the form's method attribute (enhance mode), else POST.
encodingstring'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'.
headersobjectnull Extra request headers for action submits, e.g. {'X-CSRF-Token': token}. Requests are sent with credentials: 'same-origin'.
honeypotbooleantrue 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.
honeypotNamestringnull 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).
minFillTimenumber1500 Milliseconds after render; faster submits are treated as bots (silent fake success + onSpam).
resetOnSuccessbooleanfalse Reset the form to its initial values after a successful submit.
successMessagestringnull Rendered in the polite status live region on success — or shown as Toast.success(…) when the family Toast is on the page.
submitLabelstring | falsenull Schema-mode submit button text; null uses labels.submit ('Submit'); false renders no button at all.
themestring'auto' 'auto' follows the page theme live; pin with 'light' or 'dark'. See Theming.
stylesbooleantrue false = headless: no CSS is ever injected; style the stable .vfm-* class hooks yourself. The honeypot stays hidden via inline styles even headless.
labelsobjectsee 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.

labels keys

KeyDefaultUsed 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

Field spec (schema mode)

Each entry of fields is a plain object. Labels, hints and messages are rendered with textContent; user values are never rendered as HTML.

PropertyTypeDefaultDescription
namestring— (required) Control name and the key in form.values. Specs without a name are skipped.
typestring'text' One of text, email, password, number, url, tel, textarea, checkbox, switch, radio, select, date, phone, hidden (see field types below).
labelstringthe field's name Visible <label> (or <legend> for radio groups). Required fields get a decorative * (aria-hidden).
placeholderstring— Input placeholder. On select, renders an empty-value first option with this text.
hintstring— Help text under the label, wired via aria-describedby. Plain text unless html: true.
valueany— Initial value. Boolean for checkbox/switch; matched against option values for radio/select.
requiredbooleanfalse Adds the required validator and the native required attribute.
disabledbooleanfalse Renders the control disabled (per-option for radio via option objects).
optionsarray— For radio/select: ['a', 'b'] shorthand or [{value, label, disabled}] objects.
min / maxnumber | string— Number range validator + native attributes; also forwarded to a DatePicker upgrade as its min/max.
minlength / maxlengthnumber— Length validator + native attributes.
patternstring | 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.
validatefunction | 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.
htmlbooleanfalse Per-field opt-in to render the hint as trusted HTML markup. Applies to the hint only.
rowsnumber3 textarea only: initial rows.
autoGrowbooleanfalse textarea only: grows with content as the user types.

Field types

typeRendersNotes
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.
checkboxcheckboxSingle boolean value.
switchstyled checkboxSame boolean semantics, toggle look.
radioradio groupoptions 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.

Instance methods & properties

MethodReturnsDescription
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.
PropertyTypeDescription
valuesobject (getter) Plain-object snapshot of all field values — this is the "getValues/serialize" of the API. Fresh copy on each read.
errorsobject (getter) {name: message} snapshot of current errors.
elElement The target element you passed (container in schema mode, the <form> in enhance mode).
formHTMLFormElement The live <form> element (null after destroy()).
optsobject 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.

Statics & helpers

StaticDescription
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.validatorsThe pure built-in validators (table below) — no DOM needed, usable in Node to share rules with your server.
Form.defaultsThe live defaults object — change any default once, page-wide (see Options).
Form.versionVersion string, '1.0.0'.
Form.saltCSS salt namespace token, default 'vc1'. Set your own token or false (no salting) before the first form is created.
Form.cssThe 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.varScopesConvergence 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]').

Form.validators

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.

ValidatorSignatureBehavior
requiredrequired(value, values?, labels?) Invalid when the value is empty: null/undefined, false, an empty array, or a whitespace-only string.
emailemail(value, values?, labels?) Pragmatic email check (something@something.tld, no spaces).
urlurl(value, values?, labels?) Requires an absolute http:// or https:// URL.
numbernumber(value, min, max, labels?) Invalid when not numeric, below min, or above max (either bound may be null/omitted).
lengthlength(value, min, max, labels?) String-length bounds; either bound may be null/omitted.
patternpattern(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

Events & callbacks

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

CallbackPayloadFires
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 validatefn(value, values) → null | message | Promise During validation of that field (see the field-spec table).

Declarative init (data-* attributes)

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:

AttributeOptionNotes
data-vfm—Marker: opts the form into auto-init (enhance mode).
data-vfm-validate-onvalidateOnblur | change | submit
data-vfm-encodingencodingjson | form
data-vfm-actionactionOverrides the form's action attribute.
data-vfm-methodmethodOverrides the form's method attribute.
data-vfm-successsuccessMessageSuccess text (Toast if present).
data-vfm-themethemeauto | light | dark
data-vfm-honeypot-namehoneypotNameDecoy field name.
data-vfm-min-fill-timeminFillTimeParsed as a number (ms).
data-vfm-honeypothoneypotBoolean: "false" or "0" = off, anything else = on.
data-vfm-stylesstylesBoolean, same parsing; "false" = headless.
data-vfm-resetresetOnSuccessBoolean, 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>

Theming

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:

PropertyLight defaultDark defaultPurpose
--vfm-accent#5b5bd6#7b7beaFocus rings, submit button, checks, switches
--vfm-danger#e5484d#f2555aError text, invalid borders, banner
--vfm-success#1f9d5b#4ccb8fSuccess status text
--vfm-bg#ffffff#1b1d24Input background
--vfm-surface#f2f2f5#272a33Disabled inputs, hover surfaces
--vfm-text#1c1d21#e9eaf0Text color
--vfm-muted#72747e#989aa6Hints, placeholders, secondary icons
--vfm-faint#e7e7ec#31343fBorders, switch track
--vfm-accent-softrgba(91,91,214,.13)inherits light ruleFocus ring glow; derived via color-mix (14% of accent) where supported, so it follows a custom accent automatically
--vfm-danger-softrgba(229,72,77,.12)inherits light ruleInvalid focus ring, banner background; color-mix (13% of danger) where supported
--vfm-shadow0 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-radius12pxsameBanner corner radius token
--vfm-fontsystem-ui, …sameForm 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).

Accessibility

All controls are native form elements, so the keyboard model is the platform's own:

KeyWhereAction
Tab / Shift+TabanywhereMove between fields; the honeypot is excluded from the tab order (tabindex="-1").
Entertext-style inputs, submit buttonSubmit the form (runs the kit's validation; native popups are suppressed via novalidate).
Spacecheckbox / switch, radio, password toggleToggle the control / select the radio / show-hide the password.
Arrow keysradio group, selectMove selection within the group (native behavior; upgraded Select/DatePicker keep their own documented keyboard maps).

Headless mode

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.

Unstyled (styles: false)

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 }
  ]
})

Bring your own design (Tailwind)

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; }