
Form Vanilla
- 55 installs
- 8 repo stars
- Updated August 4, 2026
- bbeierle12/skill-mcp-claude
form-vanilla is a Claude Code skill that provides framework-free form validation using the HTML5 Constraint Validation API enhanced with Zod.
About
form-vanilla is a Claude Code skill for framework-free form validation using the HTML5 Constraint Validation API enhanced with Zod. It covers built-in validation attributes, validity-state properties, custom error messages, and a createFormValidator helper with blur, input, and debounce timing. A developer loads it when building forms without React or Vue or for progressive enhancement. It relies on native browser APIs.
- Framework-free forms via the HTML5 Constraint Validation API
- Native validity-state handling enhanced with Zod for complex rules
- createFormValidator with blur/input timing and debounce for progressive enhancement
Form Vanilla by the numbers
- 55 all-time installs (skills.sh)
- Ranked #1,259 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
form-vanilla capabilities & compatibility
- Capabilities
- form validation · vanilla forms · form accessibility · form security
- Use cases
- frontend
What form-vanilla says it does
Framework-free form patterns using native browser APIs enhanced with Zod.
Framework-free form validation using HTML5 Constraint Validation API enhanced with Zod for complex rules.
npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill form-vanillaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 55 |
|---|---|
| repo stars | ★ 8 |
| Last updated | August 4, 2026 |
| Repository | bbeierle12/skill-mcp-claude ↗ |
What it does
Build framework-free forms with the Constraint Validation API plus Zod for progressive enhancement.
Who is it for?
Developers building forms without a framework or adding progressive enhancement.
Skip if: React or Vue apps, which have dedicated form skills.
When should I use this skill?
Building forms without React or Vue or for progressive enhancement.
What you get
Native forms with Constraint Validation API checks plus Zod for complex rules.
- Vanilla form validator helper
- Constraint Validation API patterns
- Zod integration for plain JS forms
By the numbers
- Documents 10 validity-state properties
- createFormValidator defaults to 300ms input debounce
Files
Form Vanilla
Framework-free form patterns using native browser APIs enhanced with Zod.
Quick Start
<form id="login-form" novalidate>
<div class="form-field">
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
/>
<span class="error" aria-live="polite"></span>
</div>
<div class="form-field">
<label for="password">Password</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
minlength="8"
/>
<span class="error" aria-live="polite"></span>
</div>
<button type="submit">Sign in</button>
</form>
<script type="module">
import { createFormValidator } from './vanilla-validator.js';
import { loginSchema } from './schemas.js';
const form = document.getElementById('login-form');
const validator = createFormValidator(form, loginSchema);
form.addEventListener('submit', async (e) => {
e.preventDefault();
const result = await validator.validate();
if (result.valid) {
console.log('Submit:', result.data);
}
});
</script>HTML5 Constraint Validation API
Built-in Attributes
<!-- Required field -->
<input required />
<!-- Length constraints -->
<input minlength="3" maxlength="50" />
<!-- Number constraints -->
<input type="number" min="0" max="100" step="1" />
<!-- Pattern (regex) -->
<input pattern="[A-Za-z]{3}" title="Three letter code" />
<!-- Email validation -->
<input type="email" />
<!-- URL validation -->
<input type="url" />Validity State Properties
const input = document.querySelector('input');
// Check individual constraints
input.validity.valueMissing; // required but empty
input.validity.typeMismatch; // email/url format wrong
input.validity.patternMismatch; // regex failed
input.validity.tooShort; // < minlength
input.validity.tooLong; // > maxlength
input.validity.rangeUnderflow; // < min
input.validity.rangeOverflow; // > max
input.validity.stepMismatch; // not divisible by step
input.validity.badInput; // browser can't parse
input.validity.customError; // setCustomValidity called
// Check overall validity
input.validity.valid; // all constraints pass
input.checkValidity(); // returns boolean
input.reportValidity(); // shows browser UICustom Error Messages
const input = document.querySelector('#email');
// Set custom validation message
input.addEventListener('invalid', (e) => {
if (input.validity.valueMissing) {
input.setCustomValidity('Please enter your email address');
} else if (input.validity.typeMismatch) {
input.setCustomValidity('Please enter a valid email (e.g., name@example.com)');
}
});
// Clear custom message on input
input.addEventListener('input', () => {
input.setCustomValidity('');
});Zod Integration
Vanilla Validator Class
// vanilla-validator.ts
import { z } from 'zod';
export interface ValidationResult<T> {
valid: boolean;
data?: T;
errors: Record<string, string>;
}
export interface ValidatorOptions {
/** When to validate */
validateOn: 'blur' | 'input' | 'submit';
/** When to re-validate after error */
revalidateOn: 'input' | 'blur';
/** Debounce delay for input validation (ms) */
debounceMs?: number;
}
const defaultOptions: ValidatorOptions = {
validateOn: 'blur',
revalidateOn: 'input',
debounceMs: 300
};
export function createFormValidator<T extends z.ZodType>(
form: HTMLFormElement,
schema: T,
options: Partial<ValidatorOptions> = {}
): FormValidator<z.infer<T>> {
const opts = { ...defaultOptions, ...options };
const fieldErrors = new Map<string, string>();
const touchedFields = new Set<string>();
let debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
// Get all form fields
const fields = Array.from(form.elements).filter(
(el): el is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
el instanceof HTMLInputElement ||
el instanceof HTMLSelectElement ||
el instanceof HTMLTextAreaElement
);
// Attach event listeners
fields.forEach(field => {
if (!field.name) return;
// Blur handler (punish late)
field.addEventListener('blur', () => {
touchedFields.add(field.name);
if (opts.validateOn === 'blur') {
validateField(field.name);
}
});
// Input handler (real-time correction)
field.addEventListener('input', () => {
// Clear existing timer
const timer = debounceTimers.get(field.name);
if (timer) clearTimeout(timer);
// Only validate if already has error (correction mode)
if (fieldErrors.has(field.name) && opts.revalidateOn === 'input') {
debounceTimers.set(
field.name,
setTimeout(() => validateField(field.name), opts.debounceMs)
);
}
});
});
function getFormData(): Record<string, unknown> {
const data: Record<string, unknown> = {};
const formData = new FormData(form);
formData.forEach((value, key) => {
// Handle checkboxes
const field = form.elements.namedItem(key);
if (field instanceof HTMLInputElement && field.type === 'checkbox') {
data[key] = field.checked;
} else if (field instanceof HTMLInputElement && field.type === 'number') {
data[key] = value === '' ? undefined : Number(value);
} else {
data[key] = value;
}
});
return data;
}
function validateField(name: string): string | undefined {
const data = getFormData();
const result = schema.safeParse(data);
if (result.success) {
clearFieldError(name);
return undefined;
}
const fieldError = result.error.errors.find(e => e.path[0] === name);
if (fieldError) {
setFieldError(name, fieldError.message);
return fieldError.message;
} else {
clearFieldError(name);
return undefined;
}
}
function setFieldError(name: string, message: string): void {
fieldErrors.set(name, message);
const field = form.elements.namedItem(name) as HTMLInputElement | null;
if (!field) return;
// Set ARIA attributes
field.setAttribute('aria-invalid', 'true');
// Find error element
const fieldWrapper = field.closest('.form-field');
const errorEl = fieldWrapper?.querySelector('.error');
if (errorEl) {
errorEl.textContent = message;
field.setAttribute('aria-describedby', errorEl.id || '');
}
// Add error class
fieldWrapper?.classList.add('has-error');
fieldWrapper?.classList.remove('is-valid');
// Set custom validity for native UI
field.setCustomValidity(message);
}
function clearFieldError(name: string): void {
fieldErrors.delete(name);
const field = form.elements.namedItem(name) as HTMLInputElement | null;
if (!field) return;
// Clear ARIA
field.setAttribute('aria-invalid', 'false');
field.removeAttribute('aria-describedby');
// Clear error element
const fieldWrapper = field.closest('.form-field');
const errorEl = fieldWrapper?.querySelector('.error');
if (errorEl) {
errorEl.textContent = '';
}
// Update classes
fieldWrapper?.classList.remove('has-error');
if (touchedFields.has(name)) {
fieldWrapper?.classList.add('is-valid');
}
// Clear custom validity
field.setCustomValidity('');
}
function clearAllErrors(): void {
fieldErrors.forEach((_, name) => clearFieldError(name));
}
async function validate(): Promise<ValidationResult<z.infer<T>>> {
const data = getFormData();
const result = schema.safeParse(data);
if (result.success) {
clearAllErrors();
return { valid: true, data: result.data, errors: {} };
}
// Set errors for all fields
const errors: Record<string, string> = {};
result.error.errors.forEach(err => {
const name = String(err.path[0]);
errors[name] = err.message;
setFieldError(name, err.message);
});
// Focus first error
const firstErrorName = Object.keys(errors)[0];
if (firstErrorName) {
const field = form.elements.namedItem(firstErrorName) as HTMLElement;
field?.focus();
}
return { valid: false, errors };
}
function reset(): void {
form.reset();
clearAllErrors();
touchedFields.clear();
debounceTimers.forEach(timer => clearTimeout(timer));
debounceTimers.clear();
}
return {
validate,
validateField,
setFieldError,
clearFieldError,
clearAllErrors,
reset,
getFormData
};
}
export interface FormValidator<T> {
validate(): Promise<ValidationResult<T>>;
validateField(name: string): string | undefined;
setFieldError(name: string, message: string): void;
clearFieldError(name: string): void;
clearAllErrors(): void;
reset(): void;
getFormData(): Record<string, unknown>;
}Usage Example
<!DOCTYPE html>
<html>
<head>
<style>
.form-field {
margin-bottom: 1rem;
}
.form-field label {
display: block;
margin-bottom: 0.25rem;
}
.form-field input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.form-field.has-error input {
border-color: #dc2626;
}
.form-field.is-valid input {
border-color: #059669;
}
.form-field .error {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>
</head>
<body>
<form id="contact-form" novalidate>
<div class="form-field">
<label for="name">Name</label>
<input id="name" name="name" type="text" autocomplete="name" />
<span class="error" id="name-error" aria-live="polite"></span>
</div>
<div class="form-field">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" />
<span class="error" id="email-error" aria-live="polite"></span>
</div>
<div class="form-field">
<label for="message">Message</label>
<textarea id="message" name="message" rows="4"></textarea>
<span class="error" id="message-error" aria-live="polite"></span>
</div>
<button type="submit">Send</button>
</form>
<script type="module">
import { z } from 'https://cdn.jsdelivr.net/npm/zod@3/+esm';
import { createFormValidator } from './vanilla-validator.js';
const schema = z.object({
name: z.string().min(1, 'Please enter your name'),
email: z.string().email('Please enter a valid email'),
message: z.string().min(10, 'Message must be at least 10 characters')
});
const form = document.getElementById('contact-form');
const validator = createFormValidator(form, schema);
form.addEventListener('submit', async (e) => {
e.preventDefault();
const result = await validator.validate();
if (result.valid) {
console.log('Submitting:', result.data);
// Send to server...
alert('Message sent!');
validator.reset();
}
});
</script>
</body>
</html>Progressive Enhancement
Base HTML (Works Without JS)
<form action="/submit" method="POST">
<div class="form-field">
<label for="email">Email *</label>
<input
id="email"
name="email"
type="email"
required
autocomplete="email"
/>
</div>
<div class="form-field">
<label for="password">Password *</label>
<input
id="password"
name="password"
type="password"
required
minlength="8"
autocomplete="current-password"
/>
</div>
<button type="submit">Sign in</button>
</form>Enhanced With JS
// Only runs if JS is available
const form = document.querySelector('form');
if (form) {
// Disable native validation UI
form.setAttribute('novalidate', '');
// Add ARIA live regions for errors
form.querySelectorAll('.form-field').forEach(field => {
const input = field.querySelector('input');
if (input && input.name) {
const errorEl = document.createElement('span');
errorEl.className = 'error';
errorEl.id = `${input.name}-error`;
errorEl.setAttribute('aria-live', 'polite');
field.appendChild(errorEl);
}
});
// Attach validator
const validator = createFormValidator(form, schema);
form.addEventListener('submit', async (e) => {
e.preventDefault();
const result = await validator.validate();
if (result.valid) {
form.submit(); // Native submit
}
});
}Common Patterns
Password Visibility Toggle
<div class="form-field password-field">
<label for="password">Password</label>
<div class="input-wrapper">
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
/>
<button
type="button"
class="toggle-password"
aria-label="Show password"
>
👁
</button>
</div>
</div>
<script>
document.querySelectorAll('.toggle-password').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.previousElementSibling;
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
btn.setAttribute('aria-label', isPassword ? 'Hide password' : 'Show password');
btn.textContent = isPassword ? '🙈' : '👁';
});
});
</script>Character Counter
<div class="form-field">
<label for="bio">Bio</label>
<textarea id="bio" name="bio" maxlength="500"></textarea>
<span class="char-count"><span id="bio-count">0</span>/500</span>
</div>
<script>
const textarea = document.getElementById('bio');
const counter = document.getElementById('bio-count');
textarea.addEventListener('input', () => {
counter.textContent = textarea.value.length;
});
</script>Form Submission with Fetch
const form = document.getElementById('my-form');
const submitBtn = form.querySelector('button[type="submit"]');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const result = await validator.validate();
if (!result.valid) return;
// Disable button
submitBtn.disabled = true;
submitBtn.textContent = 'Sending...';
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name="_csrf"]').value
},
body: JSON.stringify(result.data)
});
if (!response.ok) {
const error = await response.json();
// Handle server errors
if (error.field) {
validator.setFieldError(error.field, error.message);
} else {
alert(error.message);
}
return;
}
// Success
alert('Form submitted!');
validator.reset();
} catch (err) {
alert('Network error. Please try again.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Submit';
}
});File Structure
form-vanilla/
├── SKILL.md
├── references/
│ └── constraint-validation.md # HTML5 Constraint API reference
└── scripts/
├── vanilla-validator.ts # Main validator class
├── vanilla-validator.js # Compiled JS
├── progressive-enhance.js # Progressive enhancement utils
└── examples/
├── login-form.html
├── contact-form.html
└── checkout-form.htmlReference
references/constraint-validation.md— HTML5 Constraint Validation API reference
{
"name": "form-vanilla",
"description": "Framework-free form validation using HTML5 Constraint Validation API enhanced with Zod for complex rules. Use when building forms without React/Vue or for progressive enhancement.",
"tags": [
"forms",
"javascript",
"html",
"code-generation"
],
"sub_skills": [],
"source": "claude-user",
"type": "template",
"depends_on": [],
"enhances": [],
"last_reviewed_at": "2026-06-10",
"review_score": 56,
"relevance_tier": "B"
}
/**
* Vanilla Form Validator
*
* Framework-free form validation using HTML5 Constraint Validation API
* enhanced with Zod for complex validation rules.
*
* @module vanilla-validator
*/
import { z, ZodType, ZodError } from 'zod';
// =============================================================================
// TYPES
// =============================================================================
export interface FormValidatorOptions {
/** Validate on blur (default: true) */
validateOnBlur?: boolean;
/** Validate on input after first error (default: true) */
validateOnInput?: boolean;
/** Custom error messages */
messages?: Record<string, string>;
/** Custom error display function */
displayError?: (field: HTMLElement, message: string) => void;
/** Custom error clear function */
clearError?: (field: HTMLElement) => void;
/** Focus first error on submit */
focusFirstError?: boolean;
}
export interface ValidationResult<T = unknown> {
/** Whether validation passed */
valid: boolean;
/** Validated and typed data (if valid) */
data?: T;
/** Errors by field name */
errors: Record<string, string>;
/** First error message */
firstError?: string;
}
export interface FormValidator<T = unknown> {
/** Validate entire form */
validate: () => Promise<ValidationResult<T>>;
/** Validate single field */
validateField: (name: string) => Promise<string | undefined>;
/** Get form data as object */
getData: () => Record<string, unknown>;
/** Reset form and clear errors */
reset: () => void;
/** Destroy validator (remove listeners) */
destroy: () => void;
}
// =============================================================================
// DEFAULT ERROR MESSAGES
// =============================================================================
const DEFAULT_MESSAGES: Record<string, string> = {
valueMissing: 'This field is required',
typeMismatch: 'Please enter a valid value',
patternMismatch: 'Please match the requested format',
tooShort: 'Please enter at least {minLength} characters',
tooLong: 'Please enter no more than {maxLength} characters',
rangeUnderflow: 'Value must be at least {min}',
rangeOverflow: 'Value must be at most {max}',
stepMismatch: 'Please enter a valid value',
badInput: 'Please enter a valid value',
customError: 'Please enter a valid value'
};
// =============================================================================
// CREATE FORM VALIDATOR
// =============================================================================
/**
* Create a form validator with Zod schema
*
* @example
* ```js
* import { createFormValidator } from './vanilla-validator.js';
* import { z } from 'zod';
*
* const schema = z.object({
* email: z.string().email('Invalid email'),
* password: z.string().min(8, 'Min 8 characters')
* });
*
* const form = document.getElementById('my-form');
* const validator = createFormValidator(form, schema);
*
* form.addEventListener('submit', async (e) => {
* e.preventDefault();
* const result = await validator.validate();
* if (result.valid) {
* // Submit result.data
* }
* });
* ```
*/
export function createFormValidator<T extends ZodType>(
form: HTMLFormElement,
schema: T,
options: FormValidatorOptions = {}
): FormValidator<z.infer<T>> {
const {
validateOnBlur = true,
validateOnInput = true,
messages = {},
displayError = defaultDisplayError,
clearError = defaultClearError,
focusFirstError = true
} = options;
type FormData = z.infer<T>;
// Track which fields have shown errors
const errorShown = new Set<string>();
// Event handlers (stored for cleanup)
const handlers = new Map<Element, { blur: () => void; input: () => void }>();
// ==========================================================================
// HELPERS
// ==========================================================================
function getFormData(): Record<string, unknown> {
const formData = new FormData(form);
const data: Record<string, unknown> = {};
for (const [key, value] of formData.entries()) {
// Handle multiple values (checkboxes, multi-select)
if (data[key]) {
if (Array.isArray(data[key])) {
(data[key] as unknown[]).push(value);
} else {
data[key] = [data[key], value];
}
} else {
data[key] = value;
}
}
// Handle unchecked checkboxes
form.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
const input = checkbox as HTMLInputElement;
if (!(input.name in data)) {
data[input.name] = false;
} else if (data[input.name] === 'on') {
data[input.name] = true;
}
});
return data;
}
function getConstraintMessage(input: HTMLInputElement): string {
const validity = input.validity;
const allMessages = { ...DEFAULT_MESSAGES, ...messages };
for (const [key, message] of Object.entries(allMessages)) {
if (validity[key as keyof ValidityState]) {
return message
.replace('{minLength}', input.minLength.toString())
.replace('{maxLength}', input.maxLength.toString())
.replace('{min}', input.min)
.replace('{max}', input.max);
}
}
return input.validationMessage || 'Invalid value';
}
function getField(name: string): HTMLElement | null {
return form.querySelector(`[name="${name}"]`);
}
function getFieldWrapper(field: HTMLElement): HTMLElement {
return field.closest('.form-field') || field.parentElement || field;
}
// ==========================================================================
// ERROR DISPLAY
// ==========================================================================
function defaultDisplayError(field: HTMLElement, message: string): void {
const wrapper = getFieldWrapper(field);
wrapper.classList.add('form-field--error');
wrapper.classList.remove('form-field--valid');
// Set aria-invalid
field.setAttribute('aria-invalid', 'true');
// Find or create error element
let errorEl = wrapper.querySelector('.error, .form-field__error') as HTMLElement;
if (!errorEl) {
errorEl = document.createElement('span');
errorEl.className = 'error';
errorEl.setAttribute('role', 'alert');
wrapper.appendChild(errorEl);
}
// Link error to input
const errorId = `${field.id || field.getAttribute('name')}-error`;
errorEl.id = errorId;
field.setAttribute('aria-describedby', errorId);
errorEl.textContent = message;
}
function defaultClearError(field: HTMLElement): void {
const wrapper = getFieldWrapper(field);
wrapper.classList.remove('form-field--error');
field.setAttribute('aria-invalid', 'false');
const errorEl = wrapper.querySelector('.error, .form-field__error');
if (errorEl) {
errorEl.textContent = '';
}
}
function showValidState(field: HTMLElement): void {
const wrapper = getFieldWrapper(field);
wrapper.classList.add('form-field--valid');
}
// ==========================================================================
// VALIDATION
// ==========================================================================
async function validateField(name: string): Promise<string | undefined> {
const field = getField(name);
if (!field) return undefined;
const input = field as HTMLInputElement;
// Check HTML5 constraint validation first
if (!input.validity.valid) {
const message = getConstraintMessage(input);
displayError(field, message);
errorShown.add(name);
return message;
}
// Then check Zod schema
const data = getFormData();
const result = await schema.safeParseAsync(data);
if (!result.success) {
const fieldError = result.error.errors.find(e => e.path[0] === name);
if (fieldError) {
displayError(field, fieldError.message);
errorShown.add(name);
return fieldError.message;
}
}
// Valid
clearError(field);
showValidState(field);
return undefined;
}
async function validate(): Promise<ValidationResult<FormData>> {
const data = getFormData();
const errors: Record<string, string> = {};
// Check HTML5 constraints first
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach((el) => {
const input = el as HTMLInputElement;
if (!input.validity.valid && input.name) {
errors[input.name] = getConstraintMessage(input);
}
});
// Then check Zod schema
const result = await schema.safeParseAsync(data);
if (!result.success) {
result.error.errors.forEach((err) => {
const fieldName = err.path[0] as string;
if (!errors[fieldName]) {
errors[fieldName] = err.message;
}
});
}
// Display errors
for (const [name, message] of Object.entries(errors)) {
const field = getField(name);
if (field) {
displayError(field, message);
errorShown.add(name);
}
}
// Clear valid fields
inputs.forEach((el) => {
const input = el as HTMLInputElement;
if (input.name && !errors[input.name]) {
clearError(input);
showValidState(input);
}
});
// Focus first error
if (focusFirstError && Object.keys(errors).length > 0) {
const firstErrorField = getField(Object.keys(errors)[0]);
firstErrorField?.focus();
}
const valid = Object.keys(errors).length === 0;
return {
valid,
data: valid ? (result as { success: true; data: FormData }).data : undefined,
errors,
firstError: Object.values(errors)[0]
};
}
// ==========================================================================
// EVENT LISTENERS
// ==========================================================================
function setupListeners(): void {
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach((el) => {
const input = el as HTMLInputElement;
const name = input.name;
const blurHandler = () => {
if (validateOnBlur) {
validateField(name);
}
};
const inputHandler = () => {
// Only validate on input if error has been shown (correction mode)
if (validateOnInput && errorShown.has(name)) {
validateField(name);
}
};
input.addEventListener('blur', blurHandler);
input.addEventListener('input', inputHandler);
handlers.set(input, { blur: blurHandler, input: inputHandler });
});
}
function removeListeners(): void {
handlers.forEach((handler, el) => {
el.removeEventListener('blur', handler.blur);
el.removeEventListener('input', handler.input);
});
handlers.clear();
}
// ==========================================================================
// INITIALIZE
// ==========================================================================
setupListeners();
// ==========================================================================
// PUBLIC API
// ==========================================================================
return {
validate,
validateField,
getData: getFormData,
reset() {
form.reset();
errorShown.clear();
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach((el) => {
clearError(el as HTMLElement);
const wrapper = getFieldWrapper(el as HTMLElement);
wrapper.classList.remove('form-field--valid');
});
},
destroy() {
removeListeners();
errorShown.clear();
}
};
}
// =============================================================================
// NATIVE VALIDATION ENHANCER
// =============================================================================
/**
* Enhance native HTML5 validation with custom error display
*
* Use when you don't need Zod but want better error UX.
*
* @example
* ```js
* const form = document.getElementById('my-form');
* enhanceNativeValidation(form);
* ```
*/
export function enhanceNativeValidation(
form: HTMLFormElement,
options: Omit<FormValidatorOptions, 'validateOnInput'> = {}
): { destroy: () => void } {
const {
validateOnBlur = true,
messages = {},
displayError = defaultDisplayError,
clearError = defaultClearError,
focusFirstError = true
} = options;
const errorShown = new Set<string>();
const handlers = new Map<Element, () => void>();
function getConstraintMessage(input: HTMLInputElement): string {
const validity = input.validity;
const allMessages = { ...DEFAULT_MESSAGES, ...messages };
for (const [key, message] of Object.entries(allMessages)) {
if (validity[key as keyof ValidityState]) {
return message
.replace('{minLength}', input.minLength.toString())
.replace('{maxLength}', input.maxLength.toString())
.replace('{min}', input.min)
.replace('{max}', input.max);
}
}
return input.validationMessage || 'Invalid value';
}
function validateInput(input: HTMLInputElement): void {
if (!input.validity.valid) {
const message = getConstraintMessage(input);
displayError(input, message);
errorShown.add(input.name);
} else {
clearError(input);
errorShown.delete(input.name);
}
}
// Set up blur handlers
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach((el) => {
const input = el as HTMLInputElement;
const handler = () => {
if (validateOnBlur) {
validateInput(input);
}
};
input.addEventListener('blur', handler);
input.addEventListener('input', () => {
if (errorShown.has(input.name)) {
validateInput(input);
}
});
handlers.set(input, handler);
});
// Handle form submit
const submitHandler = (e: Event) => {
let firstError: HTMLElement | null = null;
inputs.forEach((el) => {
const input = el as HTMLInputElement;
if (!input.validity.valid) {
const message = getConstraintMessage(input);
displayError(input, message);
errorShown.add(input.name);
if (!firstError) {
firstError = input;
}
}
});
if (firstError && focusFirstError) {
e.preventDefault();
firstError.focus();
}
};
form.addEventListener('submit', submitHandler);
return {
destroy() {
handlers.forEach((handler, el) => {
el.removeEventListener('blur', handler);
});
form.removeEventListener('submit', submitHandler);
}
};
}
// =============================================================================
// CSS
// =============================================================================
export const vanillaFormCSS = `
/* Form Field */
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 1rem;
}
.form-field label {
font-weight: 500;
font-size: 0.875rem;
}
.form-field input,
.form-field select,
.form-field textarea {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
}
.form-field input:focus,
.form-field select:focus,
.form-field textarea:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Error State */
.form-field--error input,
.form-field--error select,
.form-field--error textarea {
border-color: #dc2626;
}
.form-field--error input:focus,
.form-field--error select:focus,
.form-field--error textarea:focus {
border-color: #dc2626;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
}
.form-field .error {
font-size: 0.75rem;
color: #dc2626;
min-height: 1rem;
}
/* Valid State */
.form-field--valid input,
.form-field--valid select,
.form-field--valid textarea {
border-color: #059669;
}
`;
Related skills
FAQ
Does it require a framework?
No. It uses native browser APIs and the HTML5 Constraint Validation API, enhanced with Zod for complex rules.
When should I use it?
When building forms without React or Vue or for progressive enhancement.