
Forms Router
- 51 installs
- 8 repo stars
- Updated August 4, 2026
- bbeierle12/skill-mcp-claude
forms-router is a Claude Code router skill that dispatches web form development tasks to seven specialized skills for accessibility, validation, security, UX patterns, and React, Vue, or vanilla implementations.
About
forms-router is a Claude Code router skill that dispatches web form work to seven specialized form skills. It classifies a task by framework and form type, then names which accessibility, validation, security, UX, and framework skills to load together. A developer starts here at the beginning of a form project to assemble the right skill set. It includes decision tables and common 3-4 skill combinations.
- Entry-point router for web form projects across React, Vue, and vanilla JS
- Routes to 7 specialized form skills by framework and form type
- Decision table and common combinations for standard, auth, and wizard forms
Forms Router by the numbers
- 51 all-time installs (skills.sh)
- Ranked #1,297 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
forms-router capabilities & compatibility
- Capabilities
- skill routing · react forms · form validation · form security
- Use cases
- frontend · orchestration
What forms-router says it does
Routes to 7 specialized skills based on task requirements.
**Combine** — Production forms need 3-4 skills minimum
npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill forms-routerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 51 |
|---|---|
| repo stars | ★ 8 |
| Last updated | August 4, 2026 |
| Repository | bbeierle12/skill-mcp-claude ↗ |
What it does
Start a form project by routing to the right accessibility, validation, security, and framework skills.
Who is it for?
Developers starting a form project who need to pick the right combination of form skills.
Skip if: Direct implementation code; it routes rather than implements.
When should I use this skill?
Creating forms, handling validation, or data entry across React, Vue, or vanilla JavaScript.
What you get
A matched set of form skills to load for the specific framework and form type.
- A recommended set of form skills to load
By the numbers
- Routes to 7 specialized form skills
- Production forms need 3-4 skills minimum per the router
Files
Forms Router
Routes to 7 specialized skills based on task requirements.
Routing Protocol
1. Classify — Identify framework + form type 2. Match — Apply signal matching rules below 3. Combine — Production forms need 3-4 skills minimum 4. Load — Read matched SKILL.md files before coding
Quick Route
Tier 1: Core (Always Include)
| Need | Skill | Signals |
|---|---|---|
| WCAG compliance, ARIA | form-accessibility | accessible, ARIA, screen reader, keyboard, focus, a11y |
| Zod schemas, timing | form-validation | validate, Zod, schema, error, required, pattern |
| Autocomplete, CSRF, XSS | form-security | autocomplete, password manager, CSRF, XSS, sanitize |
Tier 2: Framework
| Need | Skill | Signals |
|---|---|---|
| React Hook Form / TanStack | form-react | React, useForm, RHF, TanStack, formState |
| VeeValidate / Vuelidate | form-vue | Vue, VeeValidate, Vuelidate, v-model |
| No framework | form-vanilla | vanilla, plain JS, native, constraint validation |
Tier 3: Enhanced UX
| Need | Skill | Signals |
|---|---|---|
| Wizards, chunking, conditionals | form-ux-patterns | multi-step, wizard, progressive, conditional, stepper |
Signal Priority
When multiple signals present: 1. Framework explicit — React/Vue/vanilla determines Tier 2 choice 2. Auth context — Login/registration triggers form-security priority 3. Complexity — Wizard/multi-step triggers form-ux-patterns 4. Default — Always include all Tier 1 skills
Common Combinations
Standard Production Form (4 skills)
form-accessibility → WCAG, ARIA binding
form-validation → Zod schemas, timing
form-react → RHF integration
form-security → autocomplete attributesSecure Auth Form (4 skills)
form-security → autocomplete, CSRF (priority)
form-accessibility → focus, error announcements
form-validation → auth schema
form-react → controlled submissionMulti-Step Wizard (4 skills)
form-ux-patterns → chunking, navigation
form-validation → per-step validation
form-accessibility → focus on step change
form-react → FormProvider contextFramework-Free Form (3 skills)
form-vanilla → Constraint Validation API
form-accessibility → manual ARIA
form-security → autocompleteDecision Table
| Framework | Form Type | Skills |
|---|---|---|
| React | Standard | accessibility + validation + security + react |
| React | Auth | security + accessibility + validation + react |
| React | Wizard | ux-patterns + validation + accessibility + react |
| Vue | Standard | accessibility + validation + security + vue |
| Vue | Complex | accessibility + validation + ux-patterns + vue |
| None | Any | vanilla + accessibility + security |
Core Principles (All Skills)
Schema-first: Define Zod schema → infer TypeScript types Timing: Reward early (✓ on valid), punish late (✗ on blur only) Autocomplete: Never optional for auth forms Chunking: Max 5-7 fields per logical group
Fallback
- No framework stated → Ask: "React, Vue, or vanilla JS?"
- Ambiguous complexity → Start with Tier 1 + framework skill
- Missing context → Default to
form-react(most common)
Reference
See references/integration-guide.md for complete wiring patterns and code examples.
{
"name": "forms-router",
"description": "Routes to 7 specialized form skills based on framework, form type, and requirements including validation, accessibility, security, and UX patterns.",
"tags": [
"forms",
"web",
"routing"
],
"sub_skills": [
{
"name": "accessibility",
"file": "references/accessibility.md",
"triggers": [
"WCAG",
"ARIA",
"screen reader",
"a11y"
]
},
{
"name": "validation",
"file": "references/validation.md",
"triggers": [
"zod",
"schema",
"validate",
"error"
]
},
{
"name": "security",
"file": "references/security.md",
"triggers": [
"CSRF",
"XSS",
"autocomplete",
"password"
]
},
{
"name": "react",
"file": "references/react.md",
"triggers": [
"React",
"useForm",
"RHF",
"TanStack"
]
},
{
"name": "vue",
"file": "references/vue.md",
"triggers": [
"Vue",
"VeeValidate",
"Vuelidate"
]
},
{
"name": "vanilla",
"file": "references/vanilla.md",
"triggers": [
"vanilla",
"plain JS",
"no framework"
]
},
{
"name": "ux-patterns",
"file": "references/ux-patterns.md",
"triggers": [
"wizard",
"multi-step",
"stepper"
]
}
],
"source": "claude-user",
"type": "router",
"depends_on": [
"form-accessibility",
"form-validation",
"form-security",
"form-react",
"form-vue",
"form-vanilla",
"form-ux-patterns"
],
"enhances": [],
"last_reviewed_at": "2026-06-12",
"review_score": 69,
"relevance_tier": "C"
}
Form Accessibility
Core Principles
1. Labels and Instructions
Every form field must have an associated label:
<!-- Explicit association -->
<label for="email">Email Address</label>
<input type="email" id="email" name="email" />
<!-- Implicit association -->
<label>
Email Address
<input type="email" name="email" />
</label>2. ARIA Attributes
<input
type="email"
id="email"
aria-describedby="email-hint email-error"
aria-invalid="true"
aria-required="true"
/>
<span id="email-hint">We'll never share your email</span>
<span id="email-error" role="alert">Please enter a valid email</span>3. Error Handling
- Use
role="alert"for dynamic error messages - Connect errors to fields with
aria-describedby - Set
aria-invalid="true"on invalid fields - Announce errors to screen readers immediately
// Announce error to screen readers
const announceError = (message) => {
const liveRegion = document.getElementById('live-region');
liveRegion.textContent = message;
};4. Focus Management
// Focus first error on submit
const focusFirstError = (form) => {
const firstError = form.querySelector('[aria-invalid="true"]');
if (firstError) {
firstError.focus();
}
};5. Keyboard Navigation
- All interactive elements must be keyboard accessible
- Tab order should follow visual order
- Custom components need proper
tabindexand key handlers
WCAG 2.1 Checklist
- [ ] 1.3.1: Labels programmatically associated
- [ ] 1.3.5: Autocomplete attributes for user data
- [ ] 2.1.1: All functionality keyboard accessible
- [ ] 2.4.6: Descriptive labels and instructions
- [ ] 3.3.1: Error identification
- [ ] 3.3.2: Labels or instructions provided
- [ ] 3.3.3: Error suggestions provided
- [ ] 4.1.2: Name, role, value for custom components
Forms Integration Guide
Complete patterns for wiring form skills together.
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ forms-router │
│ (lightweight dispatcher) │
└──────┬──────────┬──────────┬──────────┬──────────┬─────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ form- │ │ form- │ │ form- │ │ form- │ │ form- │
│validation│ │access- │ │ react │ │ security │ │ ux- │
│ │ │ibility │ │ │ │ │ │ patterns │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
▲ ▲ ▲ ▲ ▲
│ │ │ │ │
└────────────┴────────────┴────────────┴────────────┘
Shared: Zod SchemasData Flow
User Input
│
▼
┌─────────────────┐
│ Security Layer │ autocomplete, CSRF token
└────────┬────────┘
│
▼
┌─────────────────┐
│ Validation │ Zod schema check
│ (timing logic) │ Reward early, punish late
└────────┬────────┘
│
▼
┌─────────────────┐
│ Accessibility │ ARIA bindings, focus
│ Layer │ management, announcements
└────────┬────────┘
│
▼
┌─────────────────┐
│ Framework │ React/Vue/Vanilla
│ Integration │ state management
└────────┬────────┘
│
▼
Form SubmissionComplete Integration Examples
Example 1: Production Login Form (React)
// schemas/auth.ts (form-validation)
import { z } from 'zod';
export const loginSchema = z.object({
email: z
.string()
.min(1, 'Please enter your email')
.email('Please enter a valid email'),
password: z
.string()
.min(1, 'Please enter your password'),
rememberMe: z.boolean().optional().default(false)
});
export type LoginFormData = z.infer<typeof loginSchema>;// config/autocomplete.ts (form-security)
export const AUTOCOMPLETE = {
email: 'email',
currentPassword: 'current-password',
newPassword: 'new-password',
oneTimeCode: 'one-time-code'
} as const;// components/FormField.tsx (form-accessibility)
import { ReactNode } from 'react';
interface FormFieldProps {
label: string;
htmlFor: string;
error?: string;
touched?: boolean;
hint?: string;
required?: boolean;
children: ReactNode;
}
export function FormField({
label,
htmlFor,
error,
touched,
hint,
required,
children
}: FormFieldProps) {
const errorId = `${htmlFor}-error`;
const hintId = `${htmlFor}-hint`;
const showError = touched && !!error;
const showValid = touched && !error;
return (
<div className={`form-field ${showError ? 'has-error' : ''} ${showValid ? 'is-valid' : ''}`}>
<label htmlFor={htmlFor}>
{label}
{required && <span className="required" aria-hidden="true">*</span>}
</label>
{hint && (
<span id={hintId} className="hint">{hint}</span>
)}
<div className="input-wrapper">
{children}
{showValid && <CheckIcon className="valid-icon" aria-hidden="true" />}
{showError && <AlertIcon className="error-icon" aria-hidden="true" />}
</div>
{showError && (
<span id={errorId} className="error" role="alert">
{error}
</span>
)}
</div>
);
}// components/LoginForm.tsx (form-react + all integrations)
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, type LoginFormData } from '../schemas/auth';
import { AUTOCOMPLETE } from '../config/autocomplete';
import { FormField } from './FormField';
interface LoginFormProps {
onSubmit: (data: LoginFormData) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const {
register,
handleSubmit,
formState: { errors, touchedFields, isSubmitting }
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
mode: 'onBlur', // Punish late
reValidateMode: 'onChange' // Real-time correction
});
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<FormField
label="Email"
htmlFor="email"
error={errors.email?.message}
touched={touchedFields.email}
required
>
<input
id="email"
type="email"
autoComplete={AUTOCOMPLETE.email}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
{...register('email')}
/>
</FormField>
<FormField
label="Password"
htmlFor="password"
error={errors.password?.message}
touched={touchedFields.password}
required
>
<input
id="password"
type="password"
autoComplete={AUTOCOMPLETE.currentPassword}
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : undefined}
{...register('password')}
/>
</FormField>
<div className="form-field checkbox">
<label>
<input type="checkbox" {...register('rememberMe')} />
Remember me
</label>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
);
}Example 2: Registration Form with Password Strength
// schemas/registration.ts (form-validation)
import { z } from 'zod';
const passwordSchema = z
.string()
.min(1, 'Password is required')
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Include at least one uppercase letter')
.regex(/[a-z]/, 'Include at least one lowercase letter')
.regex(/[0-9]/, 'Include at least one number');
export const registrationSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Please enter a valid email'),
password: passwordSchema,
confirmPassword: z.string().min(1, 'Please confirm your password'),
acceptTerms: z
.boolean()
.refine(val => val === true, 'You must accept the terms')
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
export type RegistrationFormData = z.infer<typeof registrationSchema>;// components/PasswordStrength.tsx (form-ux-patterns)
import { useMemo } from 'react';
interface PasswordStrengthProps {
password: string;
}
export function PasswordStrength({ password }: PasswordStrengthProps) {
const strength = useMemo(() => {
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[A-Z]/.test(password)) score++;
if (/[a-z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
if (score <= 2) return { label: 'Weak', color: 'red', width: '33%' };
if (score <= 4) return { label: 'Medium', color: 'orange', width: '66%' };
return { label: 'Strong', color: 'green', width: '100%' };
}, [password]);
if (!password) return null;
return (
<div className="password-strength" aria-live="polite">
<div className="strength-bar">
<div
className="strength-fill"
style={{ width: strength.width, backgroundColor: strength.color }}
/>
</div>
<span className="strength-label">{strength.label}</span>
</div>
);
}// components/RegistrationForm.tsx
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registrationSchema, type RegistrationFormData } from '../schemas/registration';
import { AUTOCOMPLETE } from '../config/autocomplete';
import { FormField } from './FormField';
import { PasswordStrength } from './PasswordStrength';
export function RegistrationForm({ onSubmit }) {
const {
register,
handleSubmit,
control,
formState: { errors, touchedFields, isSubmitting }
} = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema),
mode: 'onBlur',
reValidateMode: 'onChange'
});
// Watch password for strength meter (real-time feedback)
const password = useWatch({ control, name: 'password', defaultValue: '' });
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<FormField
label="Email"
htmlFor="email"
error={errors.email?.message}
touched={touchedFields.email}
required
>
<input
id="email"
type="email"
autoComplete={AUTOCOMPLETE.email}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
{...register('email')}
/>
</FormField>
<FormField
label="Password"
htmlFor="password"
error={errors.password?.message}
touched={touchedFields.password}
required
>
<input
id="password"
type="password"
autoComplete={AUTOCOMPLETE.newPassword} // CRITICAL: new-password
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : 'password-strength'}
{...register('password')}
/>
<PasswordStrength password={password} />
</FormField>
<FormField
label="Confirm Password"
htmlFor="confirmPassword"
error={errors.confirmPassword?.message}
touched={touchedFields.confirmPassword}
required
>
<input
id="confirmPassword"
type="password"
autoComplete={AUTOCOMPLETE.newPassword} // Same: new-password
aria-invalid={!!errors.confirmPassword}
aria-describedby={errors.confirmPassword ? 'confirmPassword-error' : undefined}
{...register('confirmPassword')}
/>
</FormField>
<div className="form-field checkbox">
<label>
<input
type="checkbox"
{...register('acceptTerms')}
aria-invalid={!!errors.acceptTerms}
/>
I accept the <a href="/terms">Terms of Service</a>
</label>
{errors.acceptTerms && (
<span className="error" role="alert">
{errors.acceptTerms.message}
</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating account...' : 'Create account'}
</button>
</form>
);
}Example 3: Multi-Step Checkout (form-ux-patterns)
// schemas/checkout.ts (form-validation)
import { z } from 'zod';
export const contactSchema = z.object({
email: z.string().email('Valid email required'),
phone: z.string().min(10, 'Valid phone required')
});
export const shippingSchema = z.object({
fullName: z.string().min(1, 'Name required'),
street: z.string().min(1, 'Address required'),
city: z.string().min(1, 'City required'),
state: z.string().min(1, 'State required'),
zip: z.string().regex(/^\d{5}(-\d{4})?$/, 'Valid ZIP required'),
country: z.string().min(1, 'Country required')
});
export const paymentSchema = z.object({
cardName: z.string().min(1, 'Name on card required'),
cardNumber: z.string().regex(/^\d{16}$/, 'Valid card number required'),
expiry: z.string().regex(/^\d{2}\/\d{2}$/, 'MM/YY format required'),
cvc: z.string().regex(/^\d{3,4}$/, 'Valid CVC required')
});
// Combined for full validation
export const checkoutSchema = z.object({
contact: contactSchema,
shipping: shippingSchema,
payment: paymentSchema
});
export type CheckoutFormData = z.infer<typeof checkoutSchema>;// config/checkout-steps.ts (form-ux-patterns)
export interface FormChunk {
id: string;
title: string;
description: string; // Briefing (separate from fields)
fields: string[]; // Max 5-7 per chunk
schema: z.ZodType;
}
export const checkoutSteps: FormChunk[] = [
{
id: 'contact',
title: 'Contact Information',
description: 'We\'ll use this to send your order confirmation and shipping updates.',
fields: ['contact.email', 'contact.phone'],
schema: contactSchema
},
{
id: 'shipping',
title: 'Shipping Address',
description: 'Where should we send your order?',
fields: ['shipping.fullName', 'shipping.street', 'shipping.city', 'shipping.state', 'shipping.zip', 'shipping.country'],
schema: shippingSchema
},
{
id: 'payment',
title: 'Payment Method',
description: 'Your payment information is encrypted and secure.',
fields: ['payment.cardName', 'payment.cardNumber', 'payment.expiry', 'payment.cvc'],
schema: paymentSchema
}
];// components/MultiStepCheckout.tsx
import { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { checkoutSchema, type CheckoutFormData } from '../schemas/checkout';
import { checkoutSteps } from '../config/checkout-steps';
import { StepIndicator } from './StepIndicator';
import { ContactStep, ShippingStep, PaymentStep, ReviewStep } from './steps';
const stepComponents = [ContactStep, ShippingStep, PaymentStep, ReviewStep];
export function MultiStepCheckout({ onSubmit }) {
const [currentStep, setCurrentStep] = useState(0);
const methods = useForm<CheckoutFormData>({
resolver: zodResolver(checkoutSchema),
mode: 'onBlur',
reValidateMode: 'onChange',
defaultValues: {
contact: { email: '', phone: '' },
shipping: { fullName: '', street: '', city: '', state: '', zip: '', country: 'US' },
payment: { cardName: '', cardNumber: '', expiry: '', cvc: '' }
}
});
const step = checkoutSteps[currentStep];
const isLastStep = currentStep === checkoutSteps.length;
const StepComponent = stepComponents[currentStep];
const handleNext = async () => {
if (currentStep < checkoutSteps.length) {
// Validate only current step's fields
const isValid = await methods.trigger(step.fields as any);
if (isValid) {
setCurrentStep(prev => prev + 1);
// Focus management: move focus to step heading
document.getElementById('step-heading')?.focus();
}
}
};
const handleBack = () => {
setCurrentStep(prev => prev - 1);
document.getElementById('step-heading')?.focus();
};
const handleSubmitForm = methods.handleSubmit(onSubmit);
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmitForm} noValidate>
<StepIndicator
steps={checkoutSteps.map(s => s.title)}
currentStep={currentStep}
/>
{!isLastStep && (
<div className="step-header">
<h2 id="step-heading" tabIndex={-1}>{step.title}</h2>
<p className="step-description">{step.description}</p>
</div>
)}
<div className="step-content">
<StepComponent />
</div>
<div className="step-navigation">
{currentStep > 0 && (
<button type="button" onClick={handleBack}>
Back
</button>
)}
{isLastStep ? (
<button type="submit" disabled={methods.formState.isSubmitting}>
{methods.formState.isSubmitting ? 'Processing...' : 'Place Order'}
</button>
) : (
<button type="button" onClick={handleNext}>
Continue
</button>
)}
</div>
</form>
</FormProvider>
);
}Example 4: Vue Form with VeeValidate
// schemas/contact.ts (shared with Vue)
import { z } from 'zod';
export const contactSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Valid email required'),
message: z.string().min(10, 'Message must be at least 10 characters')
});
export type ContactFormData = z.infer<typeof contactSchema>;<!-- components/ContactForm.vue (form-vue) -->
<script setup lang="ts">
import { useForm, useField } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { contactSchema, type ContactFormData } from '../schemas/contact';
import FormField from './FormField.vue';
const emit = defineEmits<{
submit: [data: ContactFormData]
}>();
const { handleSubmit, meta } = useForm<ContactFormData>({
validationSchema: toTypedSchema(contactSchema),
validateOnMount: false
});
const { value: name, errorMessage: nameError, meta: nameMeta } = useField('name');
const { value: email, errorMessage: emailError, meta: emailMeta } = useField('email');
const { value: message, errorMessage: messageError, meta: messageMeta } = useField('message');
const onSubmit = handleSubmit((values) => {
emit('submit', values);
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<FormField
label="Name"
html-for="name"
:error="nameError"
:touched="nameMeta.touched"
required
>
<input
id="name"
v-model="name"
type="text"
autocomplete="name"
:aria-invalid="!!nameError"
:aria-describedby="nameError ? 'name-error' : undefined"
/>
</FormField>
<FormField
label="Email"
html-for="email"
:error="emailError"
:touched="emailMeta.touched"
required
>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
:aria-invalid="!!emailError"
:aria-describedby="emailError ? 'email-error' : undefined"
/>
</FormField>
<FormField
label="Message"
html-for="message"
:error="messageError"
:touched="messageMeta.touched"
required
>
<textarea
id="message"
v-model="message"
rows="5"
:aria-invalid="!!messageError"
:aria-describedby="messageError ? 'message-error' : undefined"
/>
</FormField>
<button type="submit" :disabled="meta.pending">
Send Message
</button>
</form>
</template>Skill Cross-References
| When using... | Also reference... |
|---|---|
| form-react | form-validation (Zod schemas), form-accessibility (ARIA), form-security (autocomplete) |
| form-vue | form-validation (Zod schemas), form-accessibility (ARIA) |
| form-vanilla | form-validation (Zod runtime), form-accessibility (manual ARIA) |
| form-ux-patterns | form-react or form-vue (form state), form-validation (per-step) |
| form-security | All others (autocomplete is universal) |
| form-accessibility | All others (a11y is universal) |
Dependency Order
1. form-validation (no dependencies - pure Zod)
2. form-security (no dependencies - pure config)
3. form-accessibility (depends on: validation for error states)
4. form-react (depends on: validation, accessibility, security)
5. form-vue (depends on: validation, accessibility, security)
6. form-vanilla (depends on: validation, accessibility, security)
7. form-ux-patterns (depends on: react/vue, validation)Testing Integration
// Example test setup combining skills
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm Integration', () => {
it('validates on blur (punish late)', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
const emailInput = screen.getByLabelText(/email/i);
// Type invalid email
await userEvent.type(emailInput, 'invalid');
// No error yet (reward early doesn't apply - no valid state)
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// Blur triggers validation (punish late)
await userEvent.tab();
// Now error appears
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/valid email/i);
});
});
it('corrects in real-time after first error', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
const emailInput = screen.getByLabelText(/email/i);
// Trigger error
await userEvent.type(emailInput, 'invalid');
await userEvent.tab();
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
// Focus back and correct
await userEvent.click(emailInput);
await userEvent.clear(emailInput);
await userEvent.type(emailInput, 'valid@email.com');
// Error clears in real-time (no blur needed)
await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
it('has correct autocomplete attributes', () => {
render(<LoginForm onSubmit={jest.fn()} />);
expect(screen.getByLabelText(/email/i)).toHaveAttribute('autocomplete', 'email');
expect(screen.getByLabelText(/password/i)).toHaveAttribute('autocomplete', 'current-password');
});
it('has correct ARIA attributes', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
const emailInput = screen.getByLabelText(/email/i);
// Initially valid
expect(emailInput).toHaveAttribute('aria-invalid', 'false');
// After error
await userEvent.type(emailInput, 'invalid');
await userEvent.tab();
await waitFor(() => {
expect(emailInput).toHaveAttribute('aria-invalid', 'true');
expect(emailInput).toHaveAttribute('aria-describedby', 'email-error');
});
});
});React Forms
React Hook Form + Zod
The recommended stack for React forms:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type FormData = z.infer<typeof schema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
await submitToAPI(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} type="email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Loading...' : 'Submit'}
</button>
</form>
);
}TanStack Form
For more complex forms with fine-grained control:
import { useForm } from '@tanstack/react-form';
import { zodValidator } from '@tanstack/zod-form-adapter';
function ComplexForm() {
const form = useForm({
defaultValues: {
email: '',
password: '',
},
onSubmit: async ({ value }) => {
await submitToAPI(value);
},
validatorAdapter: zodValidator(),
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.Field
name="email"
validators={{
onChange: z.string().email(),
}}
>
{(field) => (
<>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.errors && (
<span>{field.state.meta.errors.join(', ')}</span>
)}
</>
)}
</form.Field>
</form>
);
}Controlled vs Uncontrolled
Uncontrolled (React Hook Form default)
// Better performance - DOM manages state
<input {...register('name')} />Controlled (When needed)
// Use when you need to transform or react to values
const { control } = useForm();
<Controller
name="price"
control={control}
render={({ field }) => (
<CurrencyInput
value={field.value}
onChange={(v) => field.onChange(parseFloat(v))}
/>
)}
/>Form Arrays
import { useFieldArray } from 'react-hook-form';
function DynamicForm() {
const { control, register } = useForm({
defaultValues: {
items: [{ name: '' }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
return (
<>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ name: '' })}>
Add Item
</button>
</>
);
}Server Actions (React 19+)
'use server';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
});
export async function submitForm(formData: FormData) {
const data = Object.fromEntries(formData);
const result = schema.safeParse(data);
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// Process valid data
return { success: true };
}'use client';
import { useActionState } from 'react';
import { submitForm } from './actions';
function Form() {
const [state, action, pending] = useActionState(submitForm, null);
return (
<form action={action}>
<input name="email" type="email" />
{state?.errors?.email && <span>{state.errors.email}</span>}
<button disabled={pending}>Submit</button>
</form>
);
}Form Security
CSRF Protection
Token-Based Protection
<form method="POST" action="/submit">
<input type="hidden" name="_csrf" value="{{ csrfToken }}" />
<!-- form fields -->
</form>// Server-side validation
app.post('/submit', (req, res) => {
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
// Process form
});SameSite Cookies
// Set cookies with SameSite attribute
res.cookie('session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});XSS Prevention
Input Sanitization
import DOMPurify from 'dompurify';
const sanitizeInput = (input: string): string => {
return DOMPurify.sanitize(input, {
ALLOWED_TAGS: [], // Strip all HTML
ALLOWED_ATTR: []
});
};Output Encoding
const encodeHTML = (str: string): string => {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};Secure Password Handling
Autocomplete Attributes
<!-- Current password (login) -->
<input type="password" autocomplete="current-password" />
<!-- New password (registration/change) -->
<input type="password" autocomplete="new-password" />
<!-- Confirm password -->
<input type="password" autocomplete="new-password" />Password Visibility Toggle
<div class="password-field">
<input type="password" id="password" />
<button type="button" aria-label="Show password" onclick="togglePassword()">
<span aria-hidden="true">Show</span>
</button>
</div>Client-Side Hashing (Optional Extra Layer)
// Note: Server should still hash - this is defense in depth
const hashPassword = async (password: string): Promise<string> => {
const encoder = new TextEncoder();
const data = encoder.encode(password);
const hash = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
};Rate Limiting
const rateLimiter = new Map<string, number[]>();
const checkRateLimit = (ip: string, limit = 5, window = 60000): boolean => {
const now = Date.now();
const attempts = rateLimiter.get(ip) || [];
const recentAttempts = attempts.filter(t => now - t < window);
if (recentAttempts.length >= limit) {
return false;
}
rateLimiter.set(ip, [...recentAttempts, now]);
return true;
};Security Checklist
- [ ] CSRF tokens on all state-changing forms
- [ ] Input validation on both client and server
- [ ] Output encoding for user-generated content
- [ ] Secure autocomplete attributes
- [ ] Rate limiting on sensitive endpoints
- [ ] HTTPS only for form submissions
- [ ] HttpOnly and Secure cookie flags
Form UX Patterns
Multi-Step Wizard
State Management
interface WizardState {
currentStep: number;
totalSteps: number;
data: Record<string, unknown>;
completedSteps: Set<number>;
}
function createWizard(totalSteps: number) {
const state: WizardState = {
currentStep: 0,
totalSteps,
data: {},
completedSteps: new Set(),
};
return {
next: () => {
if (state.currentStep < totalSteps - 1) {
state.completedSteps.add(state.currentStep);
state.currentStep++;
}
},
prev: () => {
if (state.currentStep > 0) {
state.currentStep--;
}
},
goTo: (step: number) => {
if (step <= Math.max(...state.completedSteps) + 1) {
state.currentStep = step;
}
},
updateData: (data: Record<string, unknown>) => {
state.data = { ...state.data, ...data };
},
getState: () => ({ ...state }),
};
}Progress Indicator
<nav aria-label="Progress">
<ol class="wizard-steps">
<li class="step completed" aria-current="false">
<span class="step-number">1</span>
<span class="step-label">Account</span>
</li>
<li class="step current" aria-current="step">
<span class="step-number">2</span>
<span class="step-label">Profile</span>
</li>
<li class="step" aria-current="false">
<span class="step-number">3</span>
<span class="step-label">Review</span>
</li>
</ol>
</nav>Conditional Fields
Show/Hide Pattern
const schema = z.discriminatedUnion('contactMethod', [
z.object({
contactMethod: z.literal('email'),
email: z.string().email(),
}),
z.object({
contactMethod: z.literal('phone'),
phone: z.string().regex(/^\d{10}$/),
}),
]);
// React example
function ContactForm() {
const { watch, register } = useForm();
const contactMethod = watch('contactMethod');
return (
<>
<select {...register('contactMethod')}>
<option value="email">Email</option>
<option value="phone">Phone</option>
</select>
{contactMethod === 'email' && (
<input {...register('email')} type="email" />
)}
{contactMethod === 'phone' && (
<input {...register('phone')} type="tel" />
)}
</>
);
}Progressive Disclosure
Accordion Sections
<form>
<fieldset>
<legend>
<button type="button" aria-expanded="true" aria-controls="basic-info">
Basic Information
</button>
</legend>
<div id="basic-info">
<!-- Basic fields -->
</div>
</fieldset>
<fieldset>
<legend>
<button type="button" aria-expanded="false" aria-controls="advanced">
Advanced Options (Optional)
</button>
</legend>
<div id="advanced" hidden>
<!-- Advanced fields -->
</div>
</fieldset>
</form>Inline Validation Timing
Best Practices
1. Validate on blur for fields user has interacted with 2. Validate on submit for untouched fields 3. Clear errors on valid input immediately 4. Debounce real-time validation (300ms)
const validationTiming = {
// Validate after user leaves field
onBlur: true,
// Only validate dirty fields on change
onChange: (isDirty: boolean) => isDirty,
// Always validate on submit
onSubmit: true,
// Real-time for critical fields (debounced)
realTime: ['username', 'email'], // async availability checks
};Error Recovery
Inline Error Messages
<div class="field">
<label for="email">Email</label>
<input
type="email"
id="email"
aria-describedby="email-error"
aria-invalid="true"
/>
<p id="email-error" class="error" role="alert">
Please enter a valid email address (e.g., name@example.com)
</p>
</div>Error Summary
<div role="alert" aria-labelledby="error-summary-title">
<h2 id="error-summary-title">There are 2 problems with your submission</h2>
<ul>
<li><a href="#email">Email - Enter a valid email address</a></li>
<li><a href="#password">Password - Must be at least 8 characters</a></li>
</ul>
</div>Auto-Save
function useAutoSave(form, saveInterval = 30000) {
const [lastSaved, setLastSaved] = useState<Date | null>(null);
useEffect(() => {
const interval = setInterval(async () => {
const data = form.getValues();
await saveDraft(data);
setLastSaved(new Date());
}, saveInterval);
return () => clearInterval(interval);
}, []);
// Also save on blur
useEffect(() => {
const handleBlur = async () => {
const data = form.getValues();
await saveDraft(data);
setLastSaved(new Date());
};
window.addEventListener('blur', handleBlur);
return () => window.removeEventListener('blur', handleBlur);
}, []);
return lastSaved;
}Loading States
function SubmitButton({ isSubmitting }: { isSubmitting: boolean }) {
return (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Spinner aria-hidden="true" />
<span>Submitting...</span>
<span className="sr-only">Please wait</span>
</>
) : (
'Submit'
)}
</button>
);
}Form Validation
Schema-First with Zod
Define your validation schema first, derive types from it:
import { z } from 'zod';
const userSchema = z.object({
email: z.string().email('Invalid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[0-9]/, 'Must contain number'),
confirmPassword: z.string(),
age: z.coerce.number().min(18, 'Must be 18 or older'),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
type UserForm = z.infer<typeof userSchema>;Validation Strategies
1. On Submit (Default)
const onSubmit = (data: unknown) => {
const result = schema.safeParse(data);
if (!result.success) {
return result.error.flatten();
}
// Process valid data
};2. On Blur (Field-level)
const validateField = (name: string, value: unknown) => {
const fieldSchema = schema.shape[name];
return fieldSchema.safeParse(value);
};3. On Change (Real-time)
// Debounce for performance
const debouncedValidate = debounce((value) => {
schema.safeParse(value);
}, 300);Async Validation
const emailSchema = z.string().email().refine(
async (email) => {
const exists = await checkEmailExists(email);
return !exists;
},
{ message: 'Email already registered' }
);
// Use parseAsync for schemas with async refinements
const result = await emailSchema.parseAsync(email);Error Formatting
const formatErrors = (error: z.ZodError) => {
return error.issues.reduce((acc, issue) => {
const path = issue.path.join('.');
acc[path] = issue.message;
return acc;
}, {} as Record<string, string>);
};Common Patterns
Optional with Default
z.string().optional().default('')Conditional Validation
z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), email: z.string().email() }),
z.object({ type: z.literal('phone'), phone: z.string().regex(/^\d{10}$/) }),
])Transform and Validate
z.string()
.transform((val) => val.trim().toLowerCase())
.pipe(z.string().email())Vanilla JavaScript Forms
Basic Form Handling
const form = document.getElementById('myForm');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const data = Object.fromEntries(formData);
// Validate
const errors = validateForm(data);
if (Object.keys(errors).length > 0) {
displayErrors(errors);
return;
}
// Submit
await submitForm(data);
});Validation with Zod
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 characters'),
});
function validateForm(data) {
const result = schema.safeParse(data);
if (result.success) {
return {};
}
return result.error.flatten().fieldErrors;
}Constraint Validation API
const form = document.getElementById('myForm');
const emailInput = document.getElementById('email');
// Custom validation
emailInput.addEventListener('input', () => {
if (emailInput.validity.typeMismatch) {
emailInput.setCustomValidity('Please enter a valid email');
} else {
emailInput.setCustomValidity('');
}
});
// Check validity
form.addEventListener('submit', (e) => {
if (!form.checkValidity()) {
e.preventDefault();
form.reportValidity();
}
});Error Display
function displayErrors(errors) {
// Clear previous errors
document.querySelectorAll('.error').forEach(el => {
el.textContent = '';
el.previousElementSibling?.removeAttribute('aria-invalid');
});
// Display new errors
for (const [field, messages] of Object.entries(errors)) {
const input = document.querySelector(`[name="${field}"]`);
const errorEl = document.querySelector(`#${field}-error`);
if (input && errorEl) {
input.setAttribute('aria-invalid', 'true');
errorEl.textContent = messages[0];
}
}
// Focus first error
const firstError = document.querySelector('[aria-invalid="true"]');
firstError?.focus();
}Real-Time Validation
function setupRealTimeValidation(form, schema) {
const fields = form.querySelectorAll('input, select, textarea');
fields.forEach(field => {
field.addEventListener('blur', () => {
validateField(field, schema);
});
// Debounced validation on input
let timeout;
field.addEventListener('input', () => {
clearTimeout(timeout);
timeout = setTimeout(() => validateField(field, schema), 300);
});
});
}
function validateField(field, schema) {
const fieldSchema = schema.shape[field.name];
if (!fieldSchema) return;
const result = fieldSchema.safeParse(field.value);
const errorEl = document.querySelector(`#${field.name}-error`);
if (result.success) {
field.removeAttribute('aria-invalid');
if (errorEl) errorEl.textContent = '';
} else {
field.setAttribute('aria-invalid', 'true');
if (errorEl) errorEl.textContent = result.error.issues[0].message;
}
}Form Serialization
// Get all form data as object
function serializeForm(form) {
const formData = new FormData(form);
const data = {};
for (const [key, value] of formData.entries()) {
// Handle multiple values (checkboxes, multi-select)
if (data[key]) {
if (Array.isArray(data[key])) {
data[key].push(value);
} else {
data[key] = [data[key], value];
}
} else {
data[key] = value;
}
}
return data;
}
// Populate form from object
function populateForm(form, data) {
for (const [key, value] of Object.entries(data)) {
const input = form.querySelector(`[name="${key}"]`);
if (!input) continue;
if (input.type === 'checkbox') {
input.checked = Boolean(value);
} else if (input.type === 'radio') {
form.querySelector(`[name="${key}"][value="${value}"]`).checked = true;
} else {
input.value = value;
}
}
}Async Submission
async function submitForm(form, url) {
const submitBtn = form.querySelector('[type="submit"]');
const originalText = submitBtn.textContent;
try {
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting...';
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(serializeForm(form)),
});
if (!response.ok) {
const errorData = await response.json();
displayErrors(errorData.errors);
return { success: false };
}
return { success: true, data: await response.json() };
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
}Vue Forms
VeeValidate + Zod
The recommended stack for Vue forms:
<script setup lang="ts">
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
const schema = toTypedSchema(
z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 characters'),
})
);
const { handleSubmit, errors, defineField, isSubmitting } = useForm({
validationSchema: schema,
});
const [email, emailAttrs] = defineField('email');
const [password, passwordAttrs] = defineField('password');
const onSubmit = handleSubmit(async (values) => {
await submitToAPI(values);
});
</script>
<template>
<form @submit="onSubmit">
<input v-model="email" v-bind="emailAttrs" type="email" />
<span v-if="errors.email">{{ errors.email }}</span>
<input v-model="password" v-bind="passwordAttrs" type="password" />
<span v-if="errors.password">{{ errors.password }}</span>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? 'Loading...' : 'Submit' }}
</button>
</form>
</template>Composition API Pattern
<script setup lang="ts">
import { useField, useForm } from 'vee-validate';
const { handleSubmit, resetForm } = useForm();
const { value: email, errorMessage: emailError } = useField('email',
(value) => {
if (!value) return 'Email is required';
if (!/\S+@\S+\.\S+/.test(value)) return 'Invalid email';
return true;
}
);
const { value: password, errorMessage: passwordError } = useField('password',
(value) => {
if (!value) return 'Password is required';
if (value.length < 8) return 'Min 8 characters';
return true;
}
);
const onSubmit = handleSubmit((values) => {
console.log(values);
});
</script>FormKit (Alternative)
<script setup>
import { FormKit } from '@formkit/vue';
const submit = async (data) => {
await saveToAPI(data);
};
</script>
<template>
<FormKit type="form" @submit="submit">
<FormKit
type="email"
name="email"
label="Email"
validation="required|email"
/>
<FormKit
type="password"
name="password"
label="Password"
validation="required|length:8"
/>
</FormKit>
</template>Dynamic Forms
<script setup lang="ts">
import { useFieldArray, useForm } from 'vee-validate';
const { handleSubmit } = useForm({
initialValues: {
items: [{ name: '' }],
},
});
const { fields, push, remove } = useFieldArray('items');
</script>
<template>
<form @submit="handleSubmit">
<div v-for="(field, idx) in fields" :key="field.key">
<input v-model="field.value.name" />
<button type="button" @click="remove(idx)">Remove</button>
</div>
<button type="button" @click="push({ name: '' })">Add</button>
</form>
</template>Async Validation
import { useField } from 'vee-validate';
const { value: username, errorMessage } = useField('username', async (value) => {
if (!value) return 'Required';
// Debounce built-in
const exists = await checkUsername(value);
if (exists) return 'Username taken';
return true;
}, {
validateOnValueUpdate: true,
});Form-Level Validation
const { handleSubmit, setErrors } = useForm();
const onSubmit = handleSubmit(async (values) => {
try {
await submitToAPI(values);
} catch (error) {
// Set server-side errors
setErrors({
email: 'Email already registered',
});
}
});Related skills
FAQ
How many skills does it route to?
Seven specialized skills across accessibility, validation, security, UX patterns, and React, Vue, and vanilla implementations.
How many skills does a production form usually need?
The router says production forms need 3-4 skills minimum, and lists common combinations.