Ark UI
Ark UI is a headless component library built on state machines, available for Vue among other frameworks. This guide targets Ark UI v5 for Vue. No adapter package is needed, and Ark handles more of the accessibility wiring than most libraries: inside its Field, the label association, aria-invalid and the error message reference are derived for you.
Installation
Install Formisch, Valibot and Ark UI:
npm install @formisch/vue valibot @ark-ui/vueArk UI ships unstyled, so every example below leaves styling to you.
Wiring patterns
Every Ark UI part is a Vue component rather than a plain element, so there is only one pattern here: bind the field entries individually. Never use v-bind="field.props", not even on a text input. Vue treats the ref entry inside that object as a real template ref, so the spread would hand Formisch the component instance, and focus would fail on an object that has no focus method.
Because the parts are components, the element reference always needs unwrapping. Every Ark part exposes its real DOM node as $el, so one small helper covers all of them:
function elementRef(register: (element: Element | null) => void) {
return (instance: Element | ComponentPublicInstance | null) =>
register(
instance && '$el' in instance
? ((instance as ComponentPublicInstance).$el as Element | null)
: (instance as Element | null)
);
}Note also that field.input is a getter and setter rather than a method. Assigning it is what stores a value and runs validation. Do not destructure the slot: write v-slot="field" rather than v-slot="{ input }", because destructuring copies the value out of the accessor and assignments would go nowhere.
Text fields
Field.Input wraps a native input, so it accepts v-model and forwards the lifecycle handlers to the element. Since Formisch and Ark UI both export a Field, the Ark one is imported as ArkField:
<template>
<Field :of="loginForm" :path="['email']" v-slot="field">
<ArkField.Root id="login-email" :invalid="!!field.errors">
<ArkField.Label>Email</ArkField.Label>
<ArkField.Input
:ref="elementRef(field.props.ref)"
v-model="field.input"
:name="field.props.name"
:autofocus="field.props.autofocus"
type="email"
@focus="field.props.onFocus"
@change="field.props.onChange"
@blur="field.props.onBlur"
/>
<ArkField.ErrorText>{{ field.errors?.[0] }}</ArkField.ErrorText>
</ArkField.Root>
</Field>
</template>The component accepts modelValue rather than value, so v-model is the way to bind it. Set the id on ArkField.Root rather than on the input, because Ark derives the control id and the label's for from it.
Controlled components
A composite component owns its value, so bind modelValue and handle the change event, then forward the lifecycle props to its hidden native part. Registering that part is what keeps focus and submit-time error focusing working:
<template>
<Field :of="loginForm" :path="['rememberMe']" v-slot="field">
<Checkbox.Root
:ids="{ hiddenInput: 'login-remember-me' }"
:name="field.props.name"
:checked="field.input ?? false"
:invalid="!!field.errors"
@checked-change="(details) => (field.input = details.checked === true)"
>
<Checkbox.HiddenInput
:ref="elementRef(field.props.ref)"
:autofocus="field.props.autofocus"
@focus="field.props.onFocus"
@blur="field.props.onBlur"
/>
<Checkbox.Control>
<Checkbox.Indicator>✓</Checkbox.Indicator>
</Checkbox.Control>
<Checkbox.Label>Remember me</Checkbox.Label>
</Checkbox.Root>
</Field>
</template>Checkbox.HiddenInput is not optional: without it the checkbox does not react to clicks at all. Ark exposes its events as kebab-case emits, so the handler is @checked-change rather than a callback prop, and its payload can be 'indeterminate', which is normalized above.
Prefer these explicit handlers over v-model on a composite root. Vue rejects v-model outright on the array based Select.Root and Slider.Root, and while it compiles on Checkbox.Root and RadioGroup.Root, it does not typecheck the write direction, so an indeterminate state or a null selection would flow into your field unchecked.
Every Ark root also accepts an ids object, which is the supported way to give a hidden part a stable id. Setting id directly on a hidden part instead leaves the generated label pointing at the old value.
Assigning field.input runs input-mode validation, so a form configured with validate: 'change' does not validate these controls until submit. Use validate: 'input' if you want them validated as the value changes.
Displaying errors
Formisch exposes errors as [string, ...string[]] | null. Inside ArkField.Root, pass invalid and render the message in ArkField.ErrorText. Ark adds aria-invalid and wires the error reference for you, and the text only renders while the field is invalid:
<template>
<Field :of="loginForm" :path="['email']" v-slot="field">
<ArkField.Root
id="login-email"
:ids="{ errorText: 'login-email-error' }"
:invalid="!!field.errors"
>
<ArkField.Label>Email</ArkField.Label>
<ArkField.Input
:ref="elementRef(field.props.ref)"
v-model="field.input"
/>
<ArkField.ErrorText>{{ field.errors?.[0] }}</ArkField.ErrorText>
</ArkField.Root>
</Field>
</template>Only Field has an error part. Select, RadioGroup and Slider have none, so render the message yourself and point the visible control at it with aria-errormessage. Use page-unique ids rather than the field name, because field.props.name is the JSON encoded path, such as ["email"].
By default, Formisch validates on submit and revalidates on input. The validation guide explains how to change that timing.
Login form example
This complete example combines two text fields, a controlled checkbox and accessible errors:
<script setup lang="ts">
import { Checkbox } from '@ark-ui/vue/checkbox';
import { Field as ArkField } from '@ark-ui/vue/field';
import { Field, Form, type SubmitHandler, useForm } from '@formisch/vue';
import * as v from 'valibot';
import type { ComponentPublicInstance } from 'vue';
const LoginSchema = v.object({
email: v.pipe(
v.string(),
v.nonEmpty('Please enter your email.'),
v.email('The email address is badly formatted.')
),
password: v.pipe(
v.string(),
v.nonEmpty('Please enter your password.'),
v.minLength(8, 'Your password must have 8 characters or more.')
),
rememberMe: v.optional(v.boolean(), false),
});
const loginForm = useForm({
schema: LoginSchema,
initialInput: { email: '', password: '', rememberMe: false },
});
const handleSubmit: SubmitHandler<typeof LoginSchema> = (output) => {
console.log(output);
};
// Every Ark UI part is a Vue component, so the ref callback receives a
// component instance. Formisch calls `.focus()` on whatever it gets, so
// unwrap the root DOM element before registering it.
function elementRef(register: (element: Element | null) => void) {
return (instance: Element | ComponentPublicInstance | null) =>
register(
instance && '$el' in instance
? ((instance as ComponentPublicInstance).$el as Element | null)
: (instance as Element | null)
);
}
</script>
<template>
<Form :of="loginForm" @submit="handleSubmit">
<Field :of="loginForm" :path="['email']" v-slot="field">
<ArkField.Root
id="login-email"
:ids="{ errorText: 'login-email-error' }"
:invalid="!!field.errors"
>
<ArkField.Label>Email</ArkField.Label>
<ArkField.Input
:ref="elementRef(field.props.ref)"
v-model="field.input"
:name="field.props.name"
:autofocus="field.props.autofocus"
type="email"
autocomplete="email"
placeholder="jane@example.com"
@focus="field.props.onFocus"
@change="field.props.onChange"
@blur="field.props.onBlur"
/>
<ArkField.ErrorText>{{ field.errors?.[0] }}</ArkField.ErrorText>
</ArkField.Root>
</Field>
<Field :of="loginForm" :path="['password']" v-slot="field">
<ArkField.Root
id="login-password"
:ids="{ errorText: 'login-password-error' }"
:invalid="!!field.errors"
>
<ArkField.Label>Password</ArkField.Label>
<ArkField.Input
:ref="elementRef(field.props.ref)"
v-model="field.input"
:name="field.props.name"
:autofocus="field.props.autofocus"
type="password"
autocomplete="current-password"
@focus="field.props.onFocus"
@change="field.props.onChange"
@blur="field.props.onBlur"
/>
<ArkField.ErrorText>{{ field.errors?.[0] }}</ArkField.ErrorText>
</ArkField.Root>
</Field>
<Field :of="loginForm" :path="['rememberMe']" v-slot="field">
<Checkbox.Root
:ids="{ hiddenInput: 'login-remember-me' }"
:name="field.props.name"
:checked="field.input ?? false"
:invalid="!!field.errors"
@checked-change="(details) => (field.input = details.checked === true)"
>
<Checkbox.HiddenInput
:ref="elementRef(field.props.ref)"
:autofocus="field.props.autofocus"
@focus="field.props.onFocus"
@blur="field.props.onBlur"
/>
<Checkbox.Control>
<Checkbox.Indicator>✓</Checkbox.Indicator>
</Checkbox.Control>
<Checkbox.Label>Remember me</Checkbox.Label>
</Checkbox.Root>
</Field>
<button type="submit" :disabled="loginForm.isSubmitting">Login</button>
</Form>
</template>The initial input keeps every control defined from its first render. Since Field.Input wraps a real native element, @change is worth forwarding there. On the composite controls it has nothing to fire on, so it stays unwired.
Component reference
The following snippets assume a form store named form, initialized values that match the schema, and the elementRef helper from above.
Text input
<template>
<Field :of="form" :path="['email']" v-slot="field">
<ArkField.Root
id="profile-email"
:ids="{ errorText: 'profile-email-error' }"
:invalid="!!field.errors"
>
<ArkField.Label>Email</ArkField.Label>
<ArkField.Input
:ref="elementRef(field.props.ref)"
v-model="field.input"
:name="field.props.name"
:autofocus="field.props.autofocus"
type="email"
@focus="field.props.onFocus"
@change="field.props.onChange"
@blur="field.props.onBlur"
/>
<ArkField.ErrorText>{{ field.errors?.[0] }}</ArkField.ErrorText>
</ArkField.Root>
</Field>
</template>Swap ArkField.Input for ArkField.Textarea to get a multiline field with the same wiring.
Checkbox
<template>
<Field :of="form" :path="['newsletter']" v-slot="field">
<Checkbox.Root
:ids="{ hiddenInput: 'settings-newsletter' }"
:name="field.props.name"
:checked="field.input ?? false"
:invalid="!!field.errors"
@checked-change="(details) => (field.input = details.checked === true)"
>
<Checkbox.HiddenInput
:ref="elementRef(field.props.ref)"
:autofocus="field.props.autofocus"
@focus="field.props.onFocus"
@blur="field.props.onBlur"
/>
<Checkbox.Control>
<Checkbox.Indicator>✓</Checkbox.Indicator>
</Checkbox.Control>
<Checkbox.Label>Newsletter</Checkbox.Label>
</Checkbox.Root>
</Field>
</template>Select
Ark's select is array based even for a single selection, so wrap the value and unwrap the payload, narrowing it to the schema union. Items come from a collection, and since Ark UI for Vue exports no portal component, Vue's own Teleport positions the popup:
const frameworkCollection = createListCollection({
items: [
{ label: 'Angular', value: 'angular' },
{ label: 'React', value: 'react' },
{ label: 'Solid', value: 'solid' },
{ label: 'Vue', value: 'vue' },
],
});<template>
<Field :of="form" :path="['framework']" v-slot="field">
<Select.Root
:ids="{
trigger: 'project-framework',
hiddenSelect: 'project-framework-select',
}"
:collection="frameworkCollection"
:name="field.props.name"
:model-value="field.input ? [field.input] : []"
:invalid="!!field.errors"
@value-change="
(details) => (field.input = details.value[0] as typeof field.input)
"
>
<Select.Label>Framework</Select.Label>
<Select.Control>
<Select.Trigger
:autofocus="field.props.autofocus"
aria-errormessage="project-framework-error"
@focus="field.props.onFocus"
@blur="field.props.onBlur"
>
<Select.ValueText placeholder="Select a framework" />
<Select.Indicator>▾</Select.Indicator>
</Select.Trigger>
</Select.Control>
<Select.HiddenSelect :ref="elementRef(field.props.ref)" />
<span v-if="field.errors" id="project-framework-error">
{{ field.errors[0] }}
</span>
<Teleport to="body">
<Select.Positioner>
<Select.Content>
<Select.Item
v-for="item in frameworkCollection.items"
:key="item.value"
:item="item"
>
<Select.ItemText>{{ item.label }}</Select.ItemText>
</Select.Item>
</Select.Content>
</Select.Positioner>
</Teleport>
</Select.Root>
</Field>
</template>Put the focus handlers on the trigger rather than on the hidden select, because that is the element a keyboard user lands on. focus still works, since Ark forwards focus from the hidden select to the visible trigger.
Radio group
Register every item's hidden input. Formisch keeps a list of registered elements, so each radio registers itself just like a native radio group. Put autofocus on the first item only, otherwise every radio competes for focus:
<template>
<Field :of="form" :path="['plan']" v-slot="field">
<RadioGroup.Root
:ids="{ itemHiddenInput: (value) => `plan-${value}` }"
:name="field.props.name"
:model-value="field.input ?? null"
:invalid="!!field.errors"
aria-errormessage="plan-error"
@value-change="(details) => (field.input = details.value as 'hobby' | 'pro')"
>
<RadioGroup.Label>Plan</RadioGroup.Label>
<RadioGroup.Item
v-for="(plan, index) in plans"
:key="plan.value"
:value="plan.value"
>
<RadioGroup.ItemHiddenInput
:ref="elementRef(field.props.ref)"
:autofocus="index === 0 && field.props.autofocus"
:aria-invalid="field.errors ? true : undefined"
@focus="field.props.onFocus"
@blur="field.props.onBlur"
/>
<RadioGroup.ItemControl />
<RadioGroup.ItemText>{{ plan.label }}</RadioGroup.ItemText>
</RadioGroup.Item>
<span v-if="field.errors" id="plan-error">{{ field.errors[0] }}</span>
</RadioGroup.Root>
</Field>
</template>Slider
Ark's slider is array based like its select, and it uses min and max rather than minValue and maxValue. One Slider.Thumb renders exactly one thumb, and Slider.Label names it for you:
<template>
<Field :of="form" :path="['volume']" v-slot="field">
<Slider.Root
:ids="{ thumb: () => 'settings-volume-thumb' }"
:name="field.props.name"
:min="0"
:max="100"
:step="1"
:model-value="[field.input ?? 50]"
:invalid="!!field.errors"
@value-change="(details) => (field.input = details.value[0])"
>
<Slider.Label>Volume</Slider.Label>
<Slider.ValueText />
<Slider.Control>
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumb
:index="0"
:autofocus="field.props.autofocus"
aria-errormessage="settings-volume-error"
@focus="field.props.onFocus"
@blur="field.props.onBlur"
>
<Slider.HiddenInput :ref="elementRef(field.props.ref)" />
</Slider.Thumb>
</Slider.Control>
<span v-if="field.errors" id="settings-volume-error">
{{ field.errors[0] }}
</span>
</Slider.Root>
</Field>
</template>Slider.HiddenInput renders with the hidden attribute, which makes it unfocusable, so focus cannot move focus to this field. Keyboard users still reach the thumb normally, and validation, isTouched and blur validation are unaffected.
Library-specific notes
field.props.autofocus is a snapshot taken when the field mounts, not a reactive value. Formisch moves focus to the first invalid field after a failed submit on its own.
Ark UI is also available for other frameworks. The part names and the ids and invalid props are identical there, so this wiring ports with only the framework API changing. See our React, Solid and Svelte guides.
If you prefer a library where native elements stay plain elements, our Reka UI guide covers that style, at the cost of writing the accessibility attributes yourself.
Next steps
Read the input components guide to package repeated wiring into reusable controls, the controlled fields guide for the underlying pattern, and the validation guide for validation timing.