Ark UI

Ark UI is a headless component library built on state machines, available for React among other frameworks. This guide targets Ark UI v5 for React. No adapter package is needed: each component exposes the native element as one of its parts, so you spread field.props onto that part and let the component root handle the value.

Installation

Install Formisch, Valibot and Ark UI:

npm install @formisch/react valibot @ark-ui/react

Ark UI ships unstyled, so every example below leaves styling to you.

Wiring patterns

Choose the wiring from the part you are rendering:

  • If the part is a native <input>, <textarea> or <select>, spread field.props onto it and pass field.input as the value.
  • If the component owns the value, pass field.input to the root and send the root's value callback to field.onChange, then forward the lifecycle props to the component's hidden native part.

Both of those are named onChange, which makes them easy to confuse. field.onChange(value) stores a value, while field.props.onChange(event) reads the value from a DOM element. Because it reads from the element, it always produces a string, so a number, boolean or enum field must go through the component's own callback rather than the spread.

Spreading field.props

Field.Input renders a native input and merges its handlers with yours, so the whole spread works. Since Formisch and Ark UI both export a Field, the Ark one is imported as ArkField:

<Field of={loginForm} path={['email']}>
  {(field) => (
    <ArkField.Root id="login-email" invalid={field.errors !== null}>
      <ArkField.Label>Email</ArkField.Label>
      <ArkField.Input {...field.props} type="email" value={field.input ?? ''} />
      <ArkField.ErrorText>{field.errors?.[0]}</ArkField.ErrorText>
    </ArkField.Root>
  )}
</Field>

Set the id on ArkField.Root rather than on the input. Ark derives the control id and the label's htmlFor from it, so overriding the part's own id would break that association. Passing value={field.input ?? ''} next to the spread keeps the input controlled from its first render.

Controlled components

A checkbox owns its value, so the root takes checked and onCheckedChange, while Checkbox.HiddenInput takes the element reference and the lifecycle handlers. Registering that input is what keeps focus and submit-time error focusing working. The callback reports boolean | 'indeterminate', so normalize it before storing:

<Field of={loginForm} path={['rememberMe']}>
  {(field) => (
    <Checkbox.Root
      ids={{ hiddenInput: 'login-remember-me' }}
      name={field.props.name}
      checked={field.input ?? false}
      onCheckedChange={(details) => field.onChange(details.checked === true)}
      invalid={field.errors !== null}
    >
      <Checkbox.HiddenInput
        ref={field.props.ref}
        autoFocus={field.props.autoFocus}
        onFocus={field.props.onFocus}
        onBlur={field.props.onBlur}
      />
      <Checkbox.Control>
        <Checkbox.Indicator></Checkbox.Indicator>
      </Checkbox.Control>
      <Checkbox.Label>Remember me</Checkbox.Label>
    </Checkbox.Root>
  )}
</Field>

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

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 aria-errormessage to that element for you, and the error text only renders while the field is invalid:

<Field of={loginForm} path={['email']}>
  {(field) => (
    <ArkField.Root
      id="login-email"
      ids={{ errorText: 'login-email-error' }}
      invalid={field.errors !== null}
    >
      <ArkField.Label>Email</ArkField.Label>
      <ArkField.Input {...field.props} value={field.input ?? ''} />
      <ArkField.ErrorText>{field.errors?.[0]}</ArkField.ErrorText>
    </ArkField.Root>
  )}
</Field>

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:

import { Checkbox } from '@ark-ui/react/checkbox';
import { Field as ArkField } from '@ark-ui/react/field';
import { Field, Form, type SubmitHandler, useForm } from '@formisch/react';
import * as v from 'valibot';

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),
});

export default function LoginPage() {
  const loginForm = useForm({
    schema: LoginSchema,
    initialInput: {
      email: '',
      password: '',
      rememberMe: false,
    },
  });

  const handleSubmit: SubmitHandler<typeof LoginSchema> = (output) => {
    console.log(output);
  };

  return (
    <Form of={loginForm} onSubmit={handleSubmit}>
      <Field of={loginForm} path={['email']}>
        {(field) => (
          <ArkField.Root
            id="login-email"
            ids={{ errorText: 'login-email-error' }}
            invalid={field.errors !== null}
          >
            <ArkField.Label>Email</ArkField.Label>
            <ArkField.Input
              {...field.props}
              type="email"
              autoComplete="email"
              placeholder="jane@example.com"
              value={field.input ?? ''}
            />
            <ArkField.ErrorText>{field.errors?.[0]}</ArkField.ErrorText>
          </ArkField.Root>
        )}
      </Field>

      <Field of={loginForm} path={['password']}>
        {(field) => (
          <ArkField.Root
            id="login-password"
            ids={{ errorText: 'login-password-error' }}
            invalid={field.errors !== null}
          >
            <ArkField.Label>Password</ArkField.Label>
            <ArkField.Input
              {...field.props}
              type="password"
              autoComplete="current-password"
              value={field.input ?? ''}
            />
            <ArkField.ErrorText>{field.errors?.[0]}</ArkField.ErrorText>
          </ArkField.Root>
        )}
      </Field>

      <Field of={loginForm} path={['rememberMe']}>
        {(field) => (
          <Checkbox.Root
            ids={{ hiddenInput: 'login-remember-me' }}
            name={field.props.name}
            checked={field.input ?? false}
            onCheckedChange={(details) =>
              field.onChange(details.checked === true)
            }
            invalid={field.errors !== null}
          >
            <Checkbox.HiddenInput
              ref={field.props.ref}
              autoFocus={field.props.autoFocus}
              onFocus={field.props.onFocus}
              onBlur={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>
  );
}

The initial input keeps every control defined from its first render. The two text fields use the spread, while the checkbox routes its value through the root and its lifecycle props through the hidden input.

Component reference

The following snippets assume a form store named form and initialized values that match the schema.

Text input

<Field of={form} path={['email']}>
  {(field) => (
    <ArkField.Root
      id="profile-email"
      ids={{ errorText: 'profile-email-error' }}
      invalid={field.errors !== null}
    >
      <ArkField.Label>Email</ArkField.Label>
      <ArkField.Input {...field.props} type="email" value={field.input ?? ''} />
      <ArkField.ErrorText>{field.errors?.[0]}</ArkField.ErrorText>
    </ArkField.Root>
  )}
</Field>

Swap ArkField.Input for ArkField.Textarea to get a multiline field with the same wiring.

Checkbox

<Field of={form} path={['newsletter']}>
  {(field) => (
    <Checkbox.Root
      ids={{ hiddenInput: 'settings-newsletter' }}
      name={field.props.name}
      checked={field.input ?? false}
      onCheckedChange={(details) => field.onChange(details.checked === true)}
      invalid={field.errors !== null}
    >
      <Checkbox.HiddenInput
        ref={field.props.ref}
        autoFocus={field.props.autoFocus}
        onFocus={field.props.onFocus}
        onBlur={field.props.onBlur}
      />
      <Checkbox.Control>
        <Checkbox.Indicator></Checkbox.Indicator>
      </Checkbox.Control>
      <Checkbox.Label>Newsletter</Checkbox.Label>
    </Checkbox.Root>
  )}
</Field>

Select

Ark's select is array based even for a single selection, so wrap the value and unwrap the callback. Items come from a collection, and Portal is imported from Ark UI rather than from React:

const frameworkCollection = createListCollection({
  items: [
    { label: 'Angular', value: 'angular' },
    { label: 'React', value: 'react' },
    { label: 'Solid', value: 'solid' },
    { label: 'Vue', value: 'vue' },
  ],
});
<Field of={form} path={['framework']}>
  {(field) => (
    <Select.Root
      ids={{
        trigger: 'project-framework',
        hiddenSelect: 'project-framework-select',
      }}
      collection={frameworkCollection}
      name={field.props.name}
      value={field.input ? [field.input] : []}
      onValueChange={(details) =>
        field.onChange(details.value[0] as typeof field.input)
      }
      invalid={field.errors !== null}
    >
      <Select.Label>Framework</Select.Label>
      <Select.Control>
        <Select.Trigger
          autoFocus={field.props.autoFocus}
          onFocus={field.props.onFocus}
          onBlur={field.props.onBlur}
          aria-errormessage="project-framework-error"
        >
          <Select.ValueText placeholder="Select a framework" />
          <Select.Indicator></Select.Indicator>
        </Select.Trigger>
      </Select.Control>
      <Select.HiddenSelect ref={field.props.ref} />
      {field.errors && (
        <span id="project-framework-error">{field.errors[0]}</span>
      )}
      <Portal>
        <Select.Positioner>
          <Select.Content>
            {frameworkCollection.items.map((item) => (
              <Select.Item key={item.value} item={item}>
                <Select.ItemText>{item.label}</Select.ItemText>
              </Select.Item>
            ))}
          </Select.Content>
        </Select.Positioner>
      </Portal>
    </Select.Root>
  )}
</Field>

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:

<Field of={form} path={['plan']}>
  {(field) => (
    <RadioGroup.Root
      ids={{ itemHiddenInput: (value) => `plan-${value}` }}
      name={field.props.name}
      value={field.input ?? null}
      onValueChange={(details) =>
        field.onChange(details.value as 'hobby' | 'pro')
      }
      aria-errormessage="plan-error"
    >
      <RadioGroup.Label>Plan</RadioGroup.Label>
      {PLANS.map((plan, index) => (
        <RadioGroup.Item key={plan} value={plan}>
          <RadioGroup.ItemHiddenInput
            ref={field.props.ref}
            autoFocus={index === 0 && field.props.autoFocus}
            onFocus={field.props.onFocus}
            onBlur={field.props.onBlur}
            aria-invalid={field.errors !== null ? true : undefined}
          />
          <RadioGroup.ItemControl />
          <RadioGroup.ItemText>{PLAN_LABELS[plan]}</RadioGroup.ItemText>
        </RadioGroup.Item>
      ))}
      {field.errors && <span id="plan-error">{field.errors[0]}</span>}
    </RadioGroup.Root>
  )}
</Field>

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:

<Field of={form} path={['volume']}>
  {(field) => (
    <Slider.Root
      ids={{ thumb: () => 'settings-volume-thumb' }}
      name={field.props.name}
      min={0}
      max={100}
      value={[field.input ?? 50]}
      onValueChange={(details) => field.onChange(details.value[0])}
      invalid={field.errors !== null}
    >
      <Slider.Label>Volume</Slider.Label>
      <Slider.ValueText />
      <Slider.Control>
        <Slider.Track>
          <Slider.Range />
        </Slider.Track>
        <Slider.Thumb
          index={0}
          autoFocus={field.props.autoFocus}
          onFocus={field.props.onFocus}
          onBlur={field.props.onBlur}
          aria-errormessage="settings-volume-error"
        >
          <Slider.HiddenInput ref={field.props.ref} />
        </Slider.Thumb>
      </Slider.Control>
      {field.errors && (
        <span id="settings-volume-error">{field.errors[0]}</span>
      )}
    </Slider.Root>
  )}
</Field>

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 is created, not a reactive value. Formisch moves focus to the first invalid field after a failed submit on its own, so you rarely need to read it directly.

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 Solid, Vue and Svelte guides.

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