
Form Vue
- 55 installs
- 8 repo stars
- Updated August 4, 2026
- bbeierle12/skill-mcp-claude
form-vue is a Claude Code skill that provides production Vue 3 form patterns using VeeValidate (default) or Vuelidate with Zod integration and the Composition API.
About
form-vue is a Claude Code skill with production Vue 3 form patterns using VeeValidate plus Zod by default, or Vuelidate. It shows Composition API forms with useForm and useField, a reusable FormField component, and a comparison table for choosing between the two libraries. A developer loads it when building forms in Vue 3 applications. It integrates Zod through the VeeValidate adapter.
- Vue 3 form patterns with VeeValidate plus Zod (default) or Vuelidate
- Composition API forms with useForm and useField
- Reusable FormField.vue with ARIA binding and touched-state error display
Form Vue 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-vue capabilities & compatibility
- Capabilities
- vue forms · form validation · form accessibility
- Use cases
- frontend · ui design
What form-vue says it does
Production Vue 3 form patterns. Default stack: **VeeValidate + Zod**.
**Default: VeeValidate** — Better DX, native Zod support.
npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill form-vueAdd 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 Vue 3 forms with VeeValidate or Vuelidate and Zod using the Composition API.
Who is it for?
Developers building forms in Vue 3 apps with the Composition API.
Skip if: React or framework-free forms, which have their own skills.
When should I use this skill?
Building forms in Vue 3 applications with Composition API.
What you get
Typed Vue 3 forms with VeeValidate, Zod schemas, and accessible FormField components.
- Vue form components
- Reusable FormField.vue
- Zod-integrated Vue form setup
By the numbers
- VeeValidate listed at ~15KB bundle size
- Vuelidate listed at ~10KB
Files
Form Vue
Production Vue 3 form patterns. Default stack: VeeValidate + Zod.
Quick Start
npm install vee-validate @vee-validate/zod zod<script setup lang="ts">
import { useForm, useField } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
// 1. Define schema
const schema = toTypedSchema(z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 characters')
}));
// 2. Use form
const { handleSubmit, errors } = useForm({ validationSchema: schema });
const { value: email } = useField('email');
const { value: password } = useField('password');
// 3. Handle submit
const onSubmit = handleSubmit((values) => {
console.log(values);
});
</script>
<template>
<form @submit="onSubmit">
<input v-model="email" type="email" autocomplete="email" />
<span v-if="errors.email">{{ errors.email }}</span>
<input v-model="password" type="password" autocomplete="current-password" />
<span v-if="errors.password">{{ errors.password }}</span>
<button type="submit">Sign in</button>
</form>
</template>When to Use Which
| Criteria | VeeValidate | Vuelidate |
|---|---|---|
| API Style | Declarative (schema) | Imperative (rules) |
| Zod Integration | ✅ Native adapter | Manual |
| Bundle Size | ~15KB | ~10KB |
| Component Support | ✅ Built-in Field/Form | Manual binding |
| Async Validation | ✅ Built-in | ✅ Built-in |
| Cross-field Validation | ✅ Easy | More manual |
| Learning Curve | Low | Medium |
Default: VeeValidate — Better DX, native Zod support.
Use Vuelidate when:
- Need extremely fine-grained control
- Existing Vuelidate codebase
- Prefer imperative validation style
VeeValidate Patterns
Basic Form with Composition API
<script setup lang="ts">
import { useForm, useField } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { loginSchema, type LoginFormData } from './schemas';
const emit = defineEmits<{
submit: [data: LoginFormData]
}>();
// Form setup
const { handleSubmit, errors, meta } = useForm<LoginFormData>({
validationSchema: toTypedSchema(loginSchema),
validateOnMount: false
});
// Field setup
const { value: email, errorMessage: emailError, meta: emailMeta } = useField('email');
const { value: password, errorMessage: passwordError, meta: passwordMeta } = useField('password');
const { value: rememberMe } = useField('rememberMe');
// Submit handler
const onSubmit = handleSubmit((values) => {
emit('submit', values);
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<div class="form-field" :class="{ 'has-error': emailMeta.touched && emailError }">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
:aria-invalid="emailMeta.touched && !!emailError"
:aria-describedby="emailError ? 'email-error' : undefined"
/>
<span v-if="emailMeta.touched && emailError" id="email-error" role="alert">
{{ emailError }}
</span>
</div>
<div class="form-field" :class="{ 'has-error': passwordMeta.touched && passwordError }">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
:aria-invalid="passwordMeta.touched && !!passwordError"
:aria-describedby="passwordError ? 'password-error' : undefined"
/>
<span v-if="passwordMeta.touched && passwordError" id="password-error" role="alert">
{{ passwordError }}
</span>
</div>
<label class="checkbox">
<input v-model="rememberMe" type="checkbox" />
Remember me
</label>
<button type="submit" :disabled="meta.pending">
{{ meta.pending ? 'Signing in...' : 'Sign in' }}
</button>
</form>
</template>Reusable FormField Component
<!-- components/FormField.vue -->
<script setup lang="ts">
import { useField } from 'vee-validate';
import { computed, useId } from 'vue';
interface Props {
name: string;
label: string;
type?: string;
autocomplete?: string;
hint?: string;
required?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
type: 'text'
});
const fieldId = useId();
const errorId = `${fieldId}-error`;
const hintId = `${fieldId}-hint`;
const { value, errorMessage, meta } = useField(() => props.name);
const showError = computed(() => meta.touched && !!errorMessage.value);
const showValid = computed(() => meta.touched && !errorMessage.value && meta.valid);
const describedBy = computed(() => {
const ids = [];
if (props.hint) ids.push(hintId);
if (showError.value) ids.push(errorId);
return ids.length > 0 ? ids.join(' ') : undefined;
});
</script>
<template>
<div
class="form-field"
:class="{
'form-field--error': showError,
'form-field--valid': showValid
}"
>
<label :for="fieldId">
{{ label }}
<span v-if="required" class="required" aria-hidden="true">*</span>
</label>
<span v-if="hint" :id="hintId" class="hint">{{ hint }}</span>
<div class="input-wrapper">
<input
:id="fieldId"
v-model="value"
:type="type"
:autocomplete="autocomplete"
:aria-invalid="showError"
:aria-describedby="describedBy"
:aria-required="required"
/>
<span v-if="showValid" class="icon icon--valid" aria-hidden="true">✓</span>
<span v-if="showError" class="icon icon--error" aria-hidden="true">✗</span>
</div>
<span v-if="showError" :id="errorId" class="error" role="alert">
{{ errorMessage }}
</span>
</div>
</template>Using FormField Component
<script setup lang="ts">
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { loginSchema } from './schemas';
import FormField from './FormField.vue';
const { handleSubmit, meta } = useForm({
validationSchema: toTypedSchema(loginSchema)
});
const onSubmit = handleSubmit((values) => {
console.log(values);
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<FormField
name="email"
label="Email"
type="email"
autocomplete="email"
required
/>
<FormField
name="password"
label="Password"
type="password"
autocomplete="current-password"
required
/>
<button type="submit" :disabled="meta.pending">
Sign in
</button>
</form>
</template>Form with Initial Values
<script setup lang="ts">
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { profileSchema } from './schemas';
interface Props {
initialData?: {
firstName: string;
lastName: string;
email: string;
}
}
const props = defineProps<Props>();
const { handleSubmit, resetForm } = useForm({
validationSchema: toTypedSchema(profileSchema),
initialValues: props.initialData
});
// Reset to initial values
const handleCancel = () => {
resetForm();
};
// Reset to new values
const handleReset = (newValues: typeof props.initialData) => {
resetForm({ values: newValues });
};
</script>Async Validation (Username Check)
<script setup lang="ts">
import { useField } from 'vee-validate';
import { z } from 'zod';
import { toTypedSchema } from '@vee-validate/zod';
// Schema with async validation
const usernameSchema = z.string()
.min(3, 'Username must be at least 3 characters')
.refine(async (username) => {
const response = await fetch(`/api/check-username?u=${username}`);
const { available } = await response.json();
return available;
}, 'Username is already taken');
const { value, errorMessage, meta } = useField('username', toTypedSchema(usernameSchema));
</script>
<template>
<div class="form-field">
<label for="username">Username</label>
<input
id="username"
v-model="value"
type="text"
autocomplete="username"
/>
<span v-if="meta.pending" class="loading">Checking...</span>
<span v-else-if="errorMessage" class="error">{{ errorMessage }}</span>
</div>
</template>Cross-Field Validation (Password Confirmation)
<script setup lang="ts">
import { useForm, useField } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
const schema = toTypedSchema(
z.object({
password: z.string().min(8, 'Min 8 characters'),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
})
);
const { handleSubmit } = useForm({ validationSchema: schema });
const { value: password } = useField('password');
const { value: confirmPassword, errorMessage: confirmError } = useField('confirmPassword');
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<input v-model="password" type="password" placeholder="Password" />
<input v-model="confirmPassword" type="password" placeholder="Confirm password" />
<span v-if="confirmError">{{ confirmError }}</span>
</form>
</template>Field Arrays (Dynamic Fields)
<script setup lang="ts">
import { useForm, useFieldArray } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
const schema = toTypedSchema(z.object({
teammates: z.array(z.object({
name: z.string().min(1, 'Name required'),
email: z.string().email('Invalid email')
})).min(1, 'Add at least one teammate')
}));
const { handleSubmit } = useForm({
validationSchema: schema,
initialValues: {
teammates: [{ name: '', email: '' }]
}
});
const { fields, push, remove } = useFieldArray('teammates');
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<div v-for="(field, index) in fields" :key="field.key">
<FormField :name="`teammates[${index}].name`" label="Name" />
<FormField :name="`teammates[${index}].email`" label="Email" type="email" />
<button type="button" @click="remove(index)" v-if="fields.length > 1">
Remove
</button>
</div>
<button type="button" @click="push({ name: '', email: '' })">
Add teammate
</button>
<button type="submit">Submit</button>
</form>
</template>Vuelidate Patterns
Basic Form
<script setup lang="ts">
import { reactive, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, email, minLength } from '@vuelidate/validators';
const state = reactive({
email: '',
password: ''
});
const rules = computed(() => ({
email: { required, email },
password: { required, minLength: minLength(8) }
}));
const v$ = useVuelidate(rules, state);
const onSubmit = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
console.log('Submitting:', state);
};
</script>
<template>
<form @submit.prevent="onSubmit">
<div class="form-field" :class="{ 'has-error': v$.email.$error }">
<label for="email">Email</label>
<input
id="email"
v-model="state.email"
type="email"
autocomplete="email"
@blur="v$.email.$touch()"
/>
<span v-if="v$.email.$error" class="error">
{{ v$.email.$errors[0]?.$message }}
</span>
</div>
<div class="form-field" :class="{ 'has-error': v$.password.$error }">
<label for="password">Password</label>
<input
id="password"
v-model="state.password"
type="password"
autocomplete="current-password"
@blur="v$.password.$touch()"
/>
<span v-if="v$.password.$error" class="error">
{{ v$.password.$errors[0]?.$message }}
</span>
</div>
<button type="submit" :disabled="v$.$pending">
Sign in
</button>
</form>
</template>Vuelidate with Zod
<script setup lang="ts">
import { reactive } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { helpers } from '@vuelidate/validators';
import { z } from 'zod';
// Create Vuelidate validator from Zod schema
function zodValidator<T extends z.ZodType>(schema: T) {
return helpers.withMessage(
(value: unknown) => {
const result = schema.safeParse(value);
if (!result.success) {
return result.error.errors[0]?.message || 'Invalid';
}
return true;
},
(value: unknown) => {
const result = schema.safeParse(value);
return result.success;
}
);
}
const emailSchema = z.string().email('Please enter a valid email');
const passwordSchema = z.string().min(8, 'Password must be at least 8 characters');
const state = reactive({
email: '',
password: ''
});
const rules = {
email: { zodValidator: zodValidator(emailSchema) },
password: { zodValidator: zodValidator(passwordSchema) }
};
const v$ = useVuelidate(rules, state);
</script>Shared Zod Schemas
// schemas/index.ts (shared between React and Vue)
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().min(1, 'Email is required').email('Invalid email'),
password: z.string().min(1, 'Password is required'),
rememberMe: z.boolean().optional().default(false)
});
export type LoginFormData = z.infer<typeof loginSchema>;
// VeeValidate usage
import { toTypedSchema } from '@vee-validate/zod';
const veeSchema = toTypedSchema(loginSchema);
// React Hook Form usage
import { zodResolver } from '@hookform/resolvers/zod';
const rhfResolver = zodResolver(loginSchema);File Structure
form-vue/
├── SKILL.md
├── references/
│ ├── veevalidate-patterns.md # VeeValidate deep-dive
│ └── vuelidate-patterns.md # Vuelidate deep-dive
└── scripts/
├── veevalidate-form.vue # VeeValidate patterns
├── vuelidate-form.vue # Vuelidate patterns
├── form-field.vue # Reusable field component
└── schemas/ # Shared with form-validation
├── auth.ts
├── profile.ts
└── payment.tsReference
references/veevalidate-patterns.md— Complete VeeValidate patternsreferences/vuelidate-patterns.md— Vuelidate patterns
{
"name": "form-vue",
"description": "Production Vue 3 form patterns using VeeValidate and Zod for validation, error handling, and accessible form development.",
"tags": [
"forms",
"vue",
"typescript",
"code-generation"
],
"sub_skills": [],
"source": "claude-user",
"type": "template",
"depends_on": [],
"enhances": [],
"last_reviewed_at": "2026-06-11",
"review_score": 37,
"relevance_tier": "A"
}
/**
* VeeValidate Form Composables
*
* Reusable Vue 3 composables for form validation with VeeValidate + Zod.
*
* @module veevalidate-composables
*/
import { computed, ref, Ref } from 'vue';
import { useForm, useField, useFieldArray } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z, ZodType } from 'zod';
// =============================================================================
// TYPES
// =============================================================================
export interface UseZodFormOptions<T extends z.ZodType> {
/** Zod schema for validation */
schema: T;
/** Initial form values */
initialValues?: Partial<z.infer<T>>;
/** Validate on mount */
validateOnMount?: boolean;
/** Keep values on unmount */
keepValuesOnUnmount?: boolean;
}
export interface FieldConfig {
/** Field name */
name: string;
/** Initial value */
initialValue?: unknown;
/** Validate on model update */
validateOnModelUpdate?: boolean;
/** Validate on blur */
validateOnBlur?: boolean;
}
// =============================================================================
// USE ZOD FORM
// =============================================================================
/**
* Composable for VeeValidate form with Zod schema
*
* @example
* ```vue
* <script setup>
* import { useZodForm } from './veevalidate-composables';
* import { loginSchema } from './schemas';
*
* const {
* handleSubmit,
* errors,
* isSubmitting,
* defineField
* } = useZodForm({
* schema: loginSchema,
* initialValues: { email: '', password: '' }
* });
*
* const [email, emailAttrs] = defineField('email');
* const [password, passwordAttrs] = defineField('password');
*
* const onSubmit = handleSubmit((values) => {
* console.log(values);
* });
* </script>
* ```
*/
export function useZodForm<T extends z.ZodType>(options: UseZodFormOptions<T>) {
const { schema, initialValues, validateOnMount = false, keepValuesOnUnmount = false } = options;
type FormValues = z.infer<T>;
const form = useForm<FormValues>({
validationSchema: toTypedSchema(schema),
initialValues: initialValues as FormValues,
validateOnMount,
keepValuesOnUnmount
});
return {
...form,
/**
* Define a field with v-model binding
*/
defineField: form.defineField,
/**
* Check if form has any errors
*/
hasErrors: computed(() => Object.keys(form.errors.value).length > 0),
/**
* Get error message for a field
*/
getError: (fieldName: keyof FormValues) => form.errors.value[fieldName as string],
/**
* Check if a specific field is valid
*/
isFieldValid: (fieldName: keyof FormValues) => !form.errors.value[fieldName as string],
/**
* Reset form to initial values
*/
resetToInitial: () => form.resetForm({ values: initialValues as FormValues })
};
}
// =============================================================================
// USE FORM FIELD
// =============================================================================
/**
* Composable for a single form field with validation timing
*
* @example
* ```vue
* <script setup>
* const { value, error, attrs, isTouched, isValid } = useFormField({
* name: 'email',
* validateOnBlur: true
* });
* </script>
*
* <template>
* <input v-model="value" v-bind="attrs" :class="{ error: isTouched && error }" />
* <span v-if="isTouched && error">{{ error }}</span>
* </template>
* ```
*/
export function useFormField(config: FieldConfig) {
const {
name,
initialValue,
validateOnModelUpdate = true,
validateOnBlur = true
} = config;
const field = useField(name, undefined, {
initialValue,
validateOnValueUpdate: validateOnModelUpdate
});
// Track touched state
const isTouched = ref(false);
const handleBlur = () => {
isTouched.value = true;
if (validateOnBlur) {
field.validate();
}
};
return {
/** Field value (v-model) */
value: field.value,
/** Error message */
error: field.errorMessage,
/** Meta information */
meta: field.meta,
/** Whether field has been touched */
isTouched,
/** Whether field is valid */
isValid: computed(() => !field.errorMessage.value),
/** Whether to show error (touched + has error) */
showError: computed(() => isTouched.value && !!field.errorMessage.value),
/** Whether to show valid state (touched + valid) */
showValid: computed(() => isTouched.value && !field.errorMessage.value && field.meta.dirty),
/** Attributes to bind to input */
attrs: {
name,
onBlur: handleBlur,
'aria-invalid': field.errorMessage.value ? 'true' : 'false'
},
/** Manually trigger validation */
validate: field.validate,
/** Reset field */
reset: field.resetField
};
}
// =============================================================================
// USE VALIDATION TIMING
// =============================================================================
/**
* Composable for "Reward Early, Punish Late" validation timing
*
* @example
* ```vue
* <script setup>
* const { showError, showValid, visualState, handlers } = useValidationTiming(field);
* </script>
*
* <template>
* <input v-bind="handlers" :class="visualState" />
* <span v-if="showError">{{ field.errorMessage }}</span>
* </template>
* ```
*/
export function useValidationTiming(field: ReturnType<typeof useField>) {
const touched = ref(false);
const hasShownError = ref(false);
const handleBlur = () => {
touched.value = true;
field.validate();
};
const handleInput = () => {
// Only revalidate if error has been shown (correction mode)
if (hasShownError.value) {
field.validate();
}
};
// Track when error is first shown
const showError = computed(() => {
const shouldShow = touched.value && !!field.errorMessage.value;
if (shouldShow) {
hasShownError.value = true;
}
return shouldShow;
});
const showValid = computed(() => {
return field.meta.dirty && !field.errorMessage.value;
});
const visualState = computed(() => {
if (showError.value) return 'invalid';
if (showValid.value) return 'valid';
return 'idle';
});
return {
showError,
showValid,
visualState,
touched,
handlers: {
onBlur: handleBlur,
onInput: handleInput
}
};
}
// =============================================================================
// USE ASYNC VALIDATION
// =============================================================================
/**
* Composable for async validation with debouncing
*
* @example
* ```vue
* <script setup>
* const { validate, isValidating, error } = useAsyncValidation({
* validator: async (value) => {
* const { available } = await checkUsername(value);
* return available ? null : 'Username taken';
* },
* debounceMs: 500
* });
* </script>
* ```
*/
export interface UseAsyncValidationOptions<T> {
/** Async validator function */
validator: (value: T) => Promise<string | null>;
/** Debounce delay in ms */
debounceMs?: number;
/** Minimum length before validating */
minLength?: number;
}
export function useAsyncValidation<T>(options: UseAsyncValidationOptions<T>) {
const { validator, debounceMs = 500, minLength = 0 } = options;
const isValidating = ref(false);
const error = ref<string | null>(null);
let timeoutId: ReturnType<typeof setTimeout>;
const validate = async (value: T): Promise<string | null> => {
clearTimeout(timeoutId);
// Skip if too short
if (typeof value === 'string' && value.length < minLength) {
error.value = null;
return null;
}
return new Promise((resolve) => {
timeoutId = setTimeout(async () => {
isValidating.value = true;
try {
const result = await validator(value);
error.value = result;
resolve(result);
} catch (e) {
error.value = 'Validation failed';
resolve('Validation failed');
} finally {
isValidating.value = false;
}
}, debounceMs);
});
};
return {
validate,
isValidating,
error
};
}
// =============================================================================
// USE FIELD ARRAY
// =============================================================================
/**
* Composable for dynamic array fields
*
* @example
* ```vue
* <script setup>
* const { fields, push, remove, move } = useFieldArrayHelper('members');
* </script>
*
* <template>
* <div v-for="(field, idx) in fields" :key="field.key">
* <input v-model="field.value.name" />
* <button @click="remove(idx)">Remove</button>
* </div>
* <button @click="push({ name: '' })">Add Member</button>
* </template>
* ```
*/
export function useFieldArrayHelper<T = unknown>(name: string) {
const { fields, push, remove, move, insert, update, replace } = useFieldArray<T>(name);
return {
fields,
push,
remove,
move,
insert,
update,
replace,
/** Remove all items */
clear: () => {
while (fields.value.length > 0) {
remove(0);
}
},
/** Move item up */
moveUp: (index: number) => {
if (index > 0) {
move(index, index - 1);
}
},
/** Move item down */
moveDown: (index: number) => {
if (index < fields.value.length - 1) {
move(index, index + 1);
}
}
};
}
// =============================================================================
// USE FORM SUBMIT
// =============================================================================
/**
* Composable for form submission with loading and error states
*
* @example
* ```vue
* <script setup>
* const { submit, isSubmitting, submitError, clearError } = useFormSubmit(
* handleSubmit,
* async (values) => {
* await api.createUser(values);
* }
* );
* </script>
*
* <template>
* <form @submit="submit">
* <div v-if="submitError" class="error">{{ submitError }}</div>
* <button :disabled="isSubmitting">
* {{ isSubmitting ? 'Submitting...' : 'Submit' }}
* </button>
* </form>
* </template>
* ```
*/
export function useFormSubmit<T>(
handleSubmit: (cb: (values: T) => void) => (e?: Event) => Promise<void>,
onSubmit: (values: T) => Promise<void>
) {
const isSubmitting = ref(false);
const submitError = ref<string | null>(null);
const submit = handleSubmit(async (values) => {
isSubmitting.value = true;
submitError.value = null;
try {
await onSubmit(values);
} catch (e: any) {
submitError.value = e.message || 'Submission failed';
} finally {
isSubmitting.value = false;
}
});
return {
submit,
isSubmitting,
submitError,
clearError: () => { submitError.value = null; }
};
}
Related skills
FAQ
What is the default Vue stack?
VeeValidate with Zod, chosen for better developer experience and native Zod support; Vuelidate is the alternative.
Which API style does it use?
Vue 3 Composition API with useForm and useField in script setup.