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/vue

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

Contributors

Thanks to all the contributors who helped make this page better!

  • GitHub profile picture of @fabian-hiller

Partners

Thanks to our partners who support the project ideally and financially.

Sponsors

Thanks to our GitHub sponsors who support the project financially.

  • GitHub profile picture of @vasilii-kovalev
  • GitHub profile picture of @UpwayShop
  • GitHub profile picture of @ruiaraujo012
  • GitHub profile picture of @hyunbinseo
  • GitHub profile picture of @nickytonline
  • GitHub profile picture of @kibertoad
  • GitHub profile picture of @caegdeveloper
  • GitHub profile picture of @Thanaen
  • GitHub profile picture of @bmoyroud
  • GitHub profile picture of @ysknsid25
  • GitHub profile picture of @dslatkin