shadcn/ui

INFO

React only. This guide uses @kintools/form-react throughout.

shadcn/ui components are controlled and forward their props to the underlying element, so binding one to a Kin Form field is the same useWatch call as a plain <input>. No adapter, no Controller.

Try it live on StackBlitz →

Setup

npx shadcn add field input textarea label checkbox select button

field gives you Field, FieldLabel, FieldError, FieldContent, ...: layout and styling only, no form-library dependency. A wrapper reads the field with useWatch and feeds the result in:

  • data-invalid on Field
  • aria-invalid / aria-describedby on the control
  • the message into FieldError

Text inputs

import { useId } from "react";
import { type FieldApi, useWatch } from "@kintools/form-react";
 
import { Field, FieldError, FieldLabel } from "./ui/field.tsx";
import { Input } from "./ui/input.tsx";
 
export type TextFieldProps<TParentValue> = {
  api: FieldApi<string, TParentValue>;
  label: string;
  type?: string;
};
 
export function TextField<TParentValue>(
  { api, label, type = "text" }: TextFieldProps<TParentValue>,
): ReactNode {
  const field = useWatch(api);
  const id = useId();
  const errorId = `${id}-error`;
  const invalid = field.touched && field.invalid;
  const message = invalid ? field.error ?? field.schemaError : undefined;
 
  return (
    <Field data-invalid={invalid || undefined}>
      <FieldLabel htmlFor={id}>{label}</FieldLabel>
      <Input
        id={id}
        type={type}
        value={field.value}
        disabled={field.disabled}
        aria-invalid={invalid || undefined}
        aria-describedby={message ? errorId : undefined}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
      />
      <FieldError id={errorId}>{message}</FieldError>
    </Field>
  );
}

message reads field.error before field.schemaError, so a per-node validator wins over a schema result. useId() (not field.id) keeps the DOM id SSR-safe. Textarea is the same wrapper with the element swapped.

Checkbox and Switch

Checkbox and Switch are controlled by checked / onCheckedChange, which returns the next state directly (no event). Radix's checked is boolean | "indeterminate", so binding a FieldApi<boolean | "indeterminate"> needs no mapping; the generic below also accepts a plain FieldApi<boolean>. orientation="horizontal" puts the box beside the label; FieldContent keeps the error below.

import { Field, FieldContent, FieldError, FieldLabel } from "./ui/field.tsx";
import { Checkbox } from "./ui/checkbox.tsx";
 
type CheckedValue = boolean | "indeterminate";
 
export type CheckboxFieldProps<TValue extends CheckedValue, TParentValue> = {
  api: FieldApi<TValue, TParentValue>;
  label: string;
};
 
export function CheckboxField<TValue extends CheckedValue, TParentValue>(
  { api, label }: CheckboxFieldProps<TValue, TParentValue>,
): ReactNode {
  const field = useWatch(api);
  const id = useId();
  const errorId = `${id}-error`;
  const invalid = field.touched && field.invalid;
  const message = invalid ? field.error ?? field.schemaError : undefined;
 
  return (
    <Field orientation="horizontal" data-invalid={invalid || undefined}>
      <Checkbox
        id={id}
        checked={field.value}
        disabled={field.disabled}
        aria-invalid={invalid || undefined}
        aria-describedby={message ? errorId : undefined}
        onBlur={field.handleBlur}
        // Radix only emits "indeterminate" when `checked` was already
        // "indeterminate", so `next` never widens past what the field allows.
        onCheckedChange={(next) => field.handleChange(next as TValue)}
      />
      <FieldContent>
        <FieldLabel htmlFor={id}>{label}</FieldLabel>
        <FieldError id={errorId}>{message}</FieldError>
      </FieldContent>
    </Field>
  );
}

Switch is the same without "indeterminate".

Select

Select is controlled by value / onValueChange (a bare string). Its trigger has no onBlur, so call handleBlur from onOpenChange. id / aria-* go on SelectTrigger. The items come in as children (not an options prop): Select is a composition API, and this way SelectGroup / SelectSeparator / disabled items just work.

import { Field, FieldError, FieldLabel } from "./ui/field.tsx";
import {
  Select,
  SelectContent,
  SelectTrigger,
  SelectValue,
} from "./ui/select.tsx";
 
export type SelectFieldProps<TParentValue> = {
  api: FieldApi<string, TParentValue>;
  label: string;
  placeholder?: string;
  children: ReactNode;
};
 
export function SelectField<TParentValue>(
  { api, label, placeholder, children }: SelectFieldProps<TParentValue>,
): ReactNode {
  const field = useWatch(api);
  const id = useId();
  const errorId = `${id}-error`;
  const invalid = field.touched && field.invalid;
  const message = invalid ? field.error ?? field.schemaError : undefined;
 
  return (
    <Field data-invalid={invalid || undefined}>
      <FieldLabel htmlFor={id}>{label}</FieldLabel>
      <Select
        value={field.value}
        disabled={field.disabled}
        onValueChange={field.handleChange}
        onOpenChange={(open) => {
          if (!open) field.handleBlur();
        }}
      >
        <SelectTrigger
          id={id}
          aria-invalid={invalid || undefined}
          aria-describedby={message ? errorId : undefined}
        >
          <SelectValue placeholder={placeholder} />
        </SelectTrigger>
        <SelectContent>{children}</SelectContent>
      </Select>
      <FieldError id={errorId}>{message}</FieldError>
    </Field>
  );
}

The caller passes SelectItems as children (see the full example). RadioGroup is identical, with the wiring on RadioGroup itself.

Error styling

shadcn components style off aria-invalid, and Field reddens its label off data-invalid. Passing both from invalid is all it takes.

Factor out the repetition

Every wrapper repeats the same useWatch / useId / invalid / message head and Field frame. So, it may make sense to extract a render-prop component. Call it FieldWrapper, since shadcn owns Field.

import { type ReactNode, useId } from "react";
import { type FieldApi, useWatch } from "@kintools/form-react";
import { Field, FieldContent, FieldError, FieldLabel } from "./ui/field.tsx";
 
export type FieldWrapperChildProps = {
  id: string;
  "aria-invalid": boolean | undefined;
  "aria-describedby": string | undefined;
};
 
export type FieldWrapperProps<TValue, TParentValue> = {
  api: FieldApi<TValue, TParentValue>;
  label: string;
  orientation?: "vertical" | "horizontal";
  children: (
    field: FieldApi<TValue, TParentValue>,
    props: FieldWrapperChildProps,
  ) => ReactNode;
};
 
export function FieldWrapper<TValue, TParentValue>(
  { api, label, orientation = "vertical", children }: FieldWrapperProps<
    TValue,
    TParentValue
  >,
): ReactNode {
  const field = useWatch(api);
  const id = useId();
  const errorId = `${id}-error`;
  const invalid = field.touched && field.invalid;
  const message = invalid ? field.error ?? field.schemaError : undefined;
 
  const control = children(field, {
    id,
    "aria-invalid": invalid || undefined,
    "aria-describedby": message ? errorId : undefined,
  });
 
  return (
    <Field orientation={orientation} data-invalid={invalid || undefined}>
      {orientation === "horizontal"
        ? (
          <>
            {control}
            <FieldContent>
              <FieldLabel htmlFor={id}>{label}</FieldLabel>
              <FieldError id={errorId}>{message}</FieldError>
            </FieldContent>
          </>
        )
        : (
          <>
            <FieldLabel htmlFor={id}>{label}</FieldLabel>
            {control}
            <FieldError id={errorId}>{message}</FieldError>
          </>
        )}
    </Field>
  );
}
export function TextField<TParentValue>(
  { api, label, type = "text" }: TextFieldProps<TParentValue>,
): ReactNode {
  return (
    <FieldWrapper api={api} label={label}>
      {(field, props) => (
        <Input
          {...props}
          type={type}
          value={field.value}
          disabled={field.disabled}
          onBlur={field.handleBlur}
          onChange={(e) => field.handleChange(e.target.value)}
        />
      )}
    </FieldWrapper>
  );
}

The runnable example ships both forms in each wrapper file, the direct one live and this one commented.

Submit button

useWatch must not run in the component that calls useForm, or the whole form re-renders on every keystroke (see Common Pitfalls). Form-level state like submitting belongs in its own component.

import { type FormApi, useWatch } from "@kintools/form-react";
import { Button } from "./ui/button.tsx";
 
export type SubmitButtonProps<TValue> = {
  api: FormApi<TValue>;
  pendingLabel: ReactNode;
  children: ReactNode;
};
 
export function SubmitButton<TValue>(
  { api, pendingLabel, children }: SubmitButtonProps<TValue>,
): ReactNode {
  const [invalid, validating, submitting] = useWatch(
    api,
    (f) => [f.invalid, f.validating, f.submitting] as const,
  );
 
  return (
    <Button type="submit" disabled={invalid || validating || submitting}>
      {submitting ? pendingLabel : children}
    </Button>
  );
}

Full example

import type { ReactNode } from "react";
import { useForm } from "@kintools/form-react";
import { email, required } from "@kintools/form-validators";
 
import { CheckboxField } from "./components/CheckboxField.tsx";
import { SelectField } from "./components/SelectField.tsx";
import { SubmitButton } from "./components/SubmitButton.tsx";
import { TextField } from "./components/TextField.tsx";
import { SelectItem } from "./components/ui/select.tsx";
 
export function SignUpForm(): ReactNode {
  const form = useForm({
    initialValue: { email: "", role: "", acceptTerms: false },
    onSubmit: async (form) => {
      await createAccount(form.value);
    },
  });
 
  return (
    <form onSubmit={form.handleSubmit} className="grid gap-4" noValidate>
      <TextField
        api={form.field("email", { validators: [required(), email()] })}
        type="email"
        label="Email"
      />
 
      <SelectField
        api={form.field("role", { validators: required("Pick a role") })}
        label="Role"
        placeholder="Select a role"
      >
        <SelectItem value="admin">Admin</SelectItem>
        <SelectItem value="member">Member</SelectItem>
      </SelectField>
 
      <CheckboxField
        api={form.field("acceptTerms", {
          validators: (f) => (f.value ? null : "You must accept the terms"),
        })}
        label="I accept the terms"
      />
 
      <SubmitButton api={form} pendingLabel="Creating…">
        Create account
      </SubmitButton>
    </form>
  );
}

Each wrapper takes a resolved api and nothing else, so the form body reads as configuration and shadcn's component names stay contained to the wrapper files.

Runnable example

examples/shadcn-ui is this form wired up, with the shadcn primitives as npx shadcn add emits them and the theme tokens in src/index.css.

Try it live on StackBlitz →

What's next

  • Basic — where TextField and the resolved-api contract come from
  • Form Composition — reusable components for nested groups and arrays
  • ReactivityuseWatch/Watch and select in depth
  • SSR — why the wrappers use useId() for DOM ids