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 buttonfield 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-invalidonFieldaria-invalid/aria-describedbyon 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
TextFieldand the resolved-apicontract come from - Form Composition — reusable components for nested groups and arrays
- Reactivity —
useWatch/Watchandselectin depth - SSR — why the wrappers use
useId()for DOM ids