Chakra UI

Chakra UI is a component system for building accessible React applications. This guide targets Chakra UI v3, whose composition-based API differs fundamentally from v2. No adapter package is needed: native controls receive field.props, while the Ark UI based composite components map Formisch's value and lifecycle APIs to their parts.

Installation

Install Formisch, Valibot and Chakra UI, and follow the Chakra UI installation guide to set up the provider:

npm install @formisch/react valibot @chakra-ui/react @emotion/react

Wiring patterns

Choose the wiring from the component's public API:

  • If it forwards props and a ref to a native form element, spread field.props and pass field.input as its value.
  • If it exposes a details callback such as onCheckedChange or onValueChange, control it with field.input and field.onChange, then forward the remaining lifecycle props to the component's native parts.

Spreading field.props

Input and Textarea render native elements, so their wiring is the same as a plain HTML input. Chakra's Field.Root wires the label, error and aria attributes to the nested input automatically. Since both Formisch and Chakra UI export a component named Field, the Chakra one is imported as ChakraField:

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

Controlled components

Chakra's composite components expose their state through a details object and render a hidden native element for form integration. Control the value on the root, and pass the field name, ref and lifecycle props to the hidden native part:

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

The hidden input is the component's real focus target, so registering it with field.props.ref keeps Formisch's focus() working. The details.checked value can be 'indeterminate', so !!details.checked normalizes it to a boolean. Formisch's focus and blur handlers are parameterless because they only update field lifecycle state, so you can pass them directly even when the component supplies an event argument.

These examples wire fields directly so you can see the complete integration. Once a pattern repeats, wrap the parts in your own input component. The input components guide shows how.

Displaying errors

Formisch exposes errors as [string, ...string[]] | null. Wrap any control in ChakraField.Root and pass its invalid prop; ChakraField.ErrorText then renders the message, and nested native inputs receive aria-invalid and aria-describedby automatically:

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

Composite components such as Select.Root and Slider.Root additionally accept their own invalid prop to mark the visible trigger. 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 native inputs, a controlled checkbox and accessible errors:

import {
  Button,
  Field as ChakraField,
  Checkbox,
  Input,
} from '@chakra-ui/react';
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) => (
          <ChakraField.Root invalid={field.errors !== null}>
            <ChakraField.Label>Email</ChakraField.Label>
            <Input
              {...field.props}
              value={field.input ?? ''}
              type="email"
              placeholder="jane@example.com"
              autoComplete="email"
            />
            <ChakraField.ErrorText>{field.errors?.[0]}</ChakraField.ErrorText>
          </ChakraField.Root>
        )}
      </Field>

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

      <Field of={loginForm} path={['rememberMe']}>
        {(field) => (
          <Checkbox.Root
            checked={field.input ?? false}
            onCheckedChange={(details) => field.onChange(!!details.checked)}
          >
            <Checkbox.HiddenInput
              name={field.props.name}
              ref={field.props.ref}
              autoFocus={field.props.autoFocus}
              onFocus={field.props.onFocus}
              onBlur={field.props.onBlur}
            />
            <Checkbox.Control>
              <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 native inputs use the field.props spread, while the checkbox maps Chakra's details callback and hidden input explicitly.

Component reference

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

Text input

Input forwards to a native <input>, so spread field.props directly:

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

Textarea works the same way because it also renders a native element.

Checkbox

For a boolean field, control checked on the root and register the hidden input:

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

Select

Select works with a collection created by createListCollection and represents its value as a string[]. Convert between the array and Formisch's single value, and narrow the plain string back to the schema union. The hidden select redirects focus to the visible trigger, so registering it with field.props.ref keeps focus() working:

const frameworks = createListCollection({
  items: [
    { label: 'Angular', value: 'angular' },
    { label: 'React', value: 'react' },
    { label: 'Solid', value: 'solid' },
    { label: 'Vue', value: 'vue' },
  ],
});
<Field of={form} path={['framework']}>
  {(field) => (
    <ChakraField.Root invalid={field.errors !== null}>
      <Select.Root
        collection={frameworks}
        invalid={field.errors !== null}
        value={field.input ? [field.input] : []}
        onValueChange={(details) =>
          field.onChange((details.value[0] ?? undefined) as typeof field.input)
        }
      >
        <Select.HiddenSelect name={field.props.name} ref={field.props.ref} />
        <Select.Label>Framework</Select.Label>
        <Select.Control>
          <Select.Trigger
            autoFocus={field.props.autoFocus}
            onFocus={field.props.onFocus}
            onBlur={field.props.onBlur}
          >
            <Select.ValueText placeholder="Select a framework" />
          </Select.Trigger>
          <Select.IndicatorGroup>
            <Select.Indicator />
          </Select.IndicatorGroup>
        </Select.Control>
        <Portal>
          <Select.Positioner>
            <Select.Content>
              {frameworks.items.map((item) => (
                <Select.Item item={item} key={item.value}>
                  {item.label}
                  <Select.ItemIndicator />
                </Select.Item>
              ))}
            </Select.Content>
          </Select.Positioner>
        </Portal>
      </Select.Root>
      <ChakraField.ErrorText>{field.errors?.[0]}</ChakraField.ErrorText>
    </ChakraField.Root>
  )}
</Field>

Radio group

RadioGroup is controlled through its details callback, and each item renders a hidden native radio input. Register the first item's hidden input so Formisch can move focus into the group:

<Field of={form} path={['plan']}>
  {(field) => (
    <ChakraField.Root invalid={field.errors !== null}>
      <ChakraField.Label>Plan</ChakraField.Label>
      <RadioGroup.Root
        name={field.props.name}
        value={field.input ?? null}
        onValueChange={(details) =>
          field.onChange((details.value ?? undefined) as typeof field.input)
        }
      >
        <RadioGroup.Item value="hobby">
          <RadioGroup.ItemHiddenInput
            ref={field.props.ref}
            autoFocus={field.props.autoFocus}
            onFocus={field.props.onFocus}
            onBlur={field.props.onBlur}
          />
          <RadioGroup.ItemIndicator />
          <RadioGroup.ItemText>Hobby</RadioGroup.ItemText>
        </RadioGroup.Item>
        <RadioGroup.Item value="pro">
          <RadioGroup.ItemHiddenInput />
          <RadioGroup.ItemIndicator />
          <RadioGroup.ItemText>Pro</RadioGroup.ItemText>
        </RadioGroup.Item>
      </RadioGroup.Root>
      <ChakraField.ErrorText>{field.errors?.[0]}</ChakraField.ErrorText>
    </ChakraField.Root>
  )}
</Field>

Slider

Pass field.input wrapped in a one-element array for a single thumb and unwrap the details value in the callback. Slider.Label gives the slider its accessible name, and the lifecycle handlers go on the thumb, which is the keyboard focus target.

Chakra's slider keeps its focusable thumb as a div and renders its hidden input with display: none, so there is no element Formisch can register and focus. That means focus and submit-time error focusing cannot reach this field, while its value, validation and blur handling work normally:

<Field of={form} path={['volume']}>
  {(field) => (
    <ChakraField.Root invalid={field.errors !== null}>
      <Slider.Root
        invalid={field.errors !== null}
        value={[field.input ?? 50]}
        onValueChange={(details) => field.onChange(details.value[0] ?? 50)}
        min={0}
        max={100}
      >
        <Slider.Label>Volume</Slider.Label>
        <Slider.Control>
          <Slider.Track>
            <Slider.Range />
          </Slider.Track>
          <Slider.Thumb
            index={0}
            onFocus={field.props.onFocus}
            onBlur={field.props.onBlur}
          >
            <Slider.HiddenInput name={field.props.name} />
          </Slider.Thumb>
        </Slider.Control>
      </Slider.Root>
      <ChakraField.ErrorText>{field.errors?.[0]}</ChakraField.ErrorText>
    </ChakraField.Root>
  )}
</Field>

Library-specific notes

This guide covers Chakra UI v3 only. The v2 API (FormControl, isInvalid, plain onChange callbacks) is entirely different; consult the Chakra migration guide if you are upgrading.

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