React Hook Form wired to Ballmac Field: one FormField renders label, control, description and error with ids, aria-invalid and aria-describedby linked, plus a pending submit button.
Create your account
Free for 14 days. No card needed.
"use client";
import * as React from "react";
import { useForm } from "react-hook-form";
import { CheckCircle2 } from "lucide-react";
import { Checkbox } from "@/components/ballmac/checkbox";
import { Form, FormField, FormSubmit } from "@/components/ballmac/form";
import { Input } from "@/components/ballmac/input";
type Values = { name: string; email: string; terms: boolean };
export default function FormDemo() {
const form = useForm<Values>({ defaultValues: { name: "", email: "jordan@", terms: false }, mode: "onTouched" });
const [done, setDone] = React.useState<string | null>(null);
return (
<div className="w-full max-w-sm rounded-xl border bg-card p-5 shadow-sm">
<div className="mb-4">
<h3 className="text-base font-semibold">Create your account</h3>
<p className="text-sm text-muted-foreground">Free for 14 days. No card needed.</p>
</div>
<Form
form={form}
onSubmit={async (values) => {
await new Promise((r) => setTimeout(r, 900));
setDone(values.name);
}}
>
<FormField
name="name"
label="Full name"
required
rules={{ required: "Tell us your name.", minLength: { value: 2, message: "Use at least 2 characters." } }}
render={(props) => <Input autoComplete="name" placeholder="Jordan Rivera" {...props} />}
/>
<FormField
name="email"
label="Work email"
description="We send a confirmation link here."
required
rules={{
required: "Enter your email.",
pattern: { value: /^\S+@\S+\.\S+$/, message: "Enter a full email address, like name@acme.com." },
}}
render={(props) => <Input type="email" autoComplete="email" {...props} />}
/>
<FormField
name="terms"
orientation="horizontal"
label="I agree to the terms of service"
rules={{ validate: (v) => v || "Accept the terms to continue." }}
render={({ value, onChange, ref, name, onBlur, ...rest }) => (
<Checkbox ref={ref} name={name} onBlur={onBlur} checked={!!value} onCheckedChange={(v) => onChange(v === true)} {...rest} />
)}
/>
<FormSubmit pendingText="Creating account…">Create account</FormSubmit>
{done && (
<p role="status" className="flex items-center gap-2 text-sm text-chart-2">
<CheckCircle2 aria-hidden="true" className="size-4" /> Welcome, {done}. Check your inbox.
</p>
)}
</Form>
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/formInstall the dependencies.
$ pnpm add react-hook-form@^7Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/fieldCopy the source into your project.
components/ballmac/form.tsx// Ballmac UI: Form. https://ui.ballmac.com/components/form // Based on shadcn/ui Form (MIT, Copyright (c) 2023 shadcn) on React Hook Form (MIT, Copyright (c) 2019 Beier Luo), rebuilt on Ballmac Field so one FormField renders label, control, description and error with ids, aria-invalid and aria-describedby wired for you, and a submit button with a pending state. "use client"; import * as React from "react"; import { Controller, FormProvider, useFormContext, useFormState, type ControllerProps, type ControllerRenderProps, type FieldPath, type FieldValues, type SubmitErrorHandler, type SubmitHandler, type UseFormReturn, } from "react-hook-form"; import { Button, type ButtonProps } from "@/components/ballmac/button"; import { Field, FieldDescription, FieldError, FieldLabel, useFieldControl, type FieldProps, } from "@/components/ballmac/field"; import { cn } from "@/lib/utils"; type FormProps<TValues extends FieldValues> = Omit<React.ComponentProps<"form">, "onSubmit" | "onError"> & { /** The object returned by `useForm()`. */ form: UseFormReturn<TValues>; /** Called with validated values when the form is submitted. */ onSubmit: SubmitHandler<TValues>; /** Called with the errors when validation fails. By default the first invalid control is focused. */ onError?: SubmitErrorHandler<TValues>; }; /** Provides the form to every FormField and wires `<form>` to `handleSubmit`. Browser validation is off (`noValidate`) so your schema owns the messages. */ function Form<TValues extends FieldValues>({ form, onSubmit, onError, className, children, ...props }: FormProps<TValues>) { return ( <FormProvider {...form}> <form data-slot="form" noValidate onSubmit={form.handleSubmit(onSubmit, onError)} className={cn("grid w-full gap-5", className)} {...props} > {children} </form> </FormProvider> ); } /** Everything a control needs, ready to spread: `<Input {...props} />`. */ type FormControlProps<TValues extends FieldValues = FieldValues, TName extends FieldPath<TValues> = FieldPath<TValues>> = ControllerRenderProps<TValues, TName> & { id?: string; "aria-invalid"?: true; "aria-describedby"?: string; }; type FormFieldProps<TValues extends FieldValues, TName extends FieldPath<TValues>> = { /** Field name, typed from your form values (supports paths such as `address.city`). */ name: TName; /** Visible label. */ label: React.ReactNode; /** Help text under the control. It stays readable to screen readers when an error is shown. */ description?: React.ReactNode; /** Show the required asterisk. Pair it with a schema rule. */ required?: boolean; /** Label and control layout. `horizontal` suits checkboxes and switches. */ orientation?: FieldProps["orientation"]; /** Built-in validation rules (`required`, `minLength`, `pattern`, `validate`…). Not needed when you use a schema resolver. */ rules?: ControllerProps<TValues, TName>["rules"]; /** Optional form object; defaults to the nearest `<Form>`. */ form?: UseFormReturn<TValues>; /** Renders the control. Spread the props onto it: `render={(props) => <Input {...props} />}`. For checkboxes or switches map `props.value` to `checked` and `props.onChange` to `onCheckedChange`. */ render: (props: FormControlProps<TValues, TName>) => React.ReactNode; className?: string; }; function ControlHost<TValues extends FieldValues, TName extends FieldPath<TValues>>({ field, render, }: { field: ControllerRenderProps<TValues, TName>; render: FormFieldProps<TValues, TName>["render"]; }) { const control = useFieldControl(); return <>{render({ ...field, ...control } as FormControlProps<TValues, TName>)}</>; } /** One field: label, your control, description and the validation message, all linked. */ function FormField<TValues extends FieldValues, TName extends FieldPath<TValues>>({ name, label, description, required, orientation, form, rules, render, className, }: FormFieldProps<TValues, TName>) { const context = useFormContext<TValues>(); const control = (form ?? context).control; const horizontal = orientation === "horizontal"; return ( <Controller control={control} name={name} rules={rules} render={({ field, fieldState }) => ( <Field data-slot="form-field" invalid={fieldState.invalid} orientation={orientation} className={className}> {horizontal ? ( <> <ControlHost field={field} render={render} /> <div className="flex flex-1 flex-col gap-1"> <FieldLabel required={required}>{label}</FieldLabel> {description && <FieldDescription>{description}</FieldDescription>} <FieldError errors={[fieldState.error]} /> </div> </> ) : ( <> <FieldLabel required={required}>{label}</FieldLabel> <ControlHost field={field} render={render} /> {description && <FieldDescription>{description}</FieldDescription>} <FieldError errors={[fieldState.error]} /> </> )} </Field> )} /> ); } type FormSubmitProps = Omit<ButtonProps, "type" | "loading"> & { /** Text shown on the button while the submit handler is running. */ pendingText?: React.ReactNode; }; /** Submit button that shows a spinner and blocks repeat clicks while `onSubmit` is pending. */ function FormSubmit({ children, pendingText, disabled, ...props }: FormSubmitProps) { const { isSubmitting } = useFormState(); return ( <Button type="submit" loading={isSubmitting} disabled={disabled} {...props}> {isSubmitting && pendingText ? pendingText : children} </Button> ); } export { Form, FormField, FormSubmit, useFormContext, type FormProps, type FormFieldProps, type FormControlProps, type FormSubmitProps, };Update the import paths to match your project setup.
Usage
import { Form, FormField, FormSubmit, useFormContext } from "@/components/ballmac/form"The full example is in the Code tab above.
Examples
Pending submit
"use client";
import { useForm } from "react-hook-form";
import { Form, FormField, FormSubmit } from "@/components/ballmac/form";
import { Input } from "@/components/ballmac/input";
import { Textarea } from "@/components/ballmac/textarea";
type Values = { subject: string; message: string };
export default function FormStates() {
const form = useForm<Values>({ defaultValues: { subject: "", message: "" } });
return (
<Form form={form} onSubmit={() => new Promise((r) => setTimeout(r, 1500))} className="max-w-sm">
<FormField name="subject" label="Subject" rules={{ required: "Add a subject." }} render={(p) => <Input {...p} />} />
<FormField
name="message"
label="Message"
description="Up to 200 characters."
rules={{ maxLength: { value: 200, message: "Keep it under 200 characters." } }}
render={(p) => <Textarea rows={3} {...p} />}
/>
<FormSubmit pendingText="Sending…">Send message</FormSubmit>
</Form>
);
}API reference
<Form>
| Prop | Type | Default |
|---|---|---|
form*The object returned by `useForm()`. | UseFormReturn<TValues> | — |
onSubmit*Called with validated values when the form is submitted. | SubmitHandler<TValues> | — |
onErrorCalled with the errors when validation fails. By default the first invalid control is focused. | SubmitErrorHandler<TValues> | — |
<FormControl>
| Prop | Type | Default |
|---|---|---|
id | string | — |
"aria-invalid" | true | — |
"aria-describedby" | string | — |
<FormField>
| Prop | Type | Default |
|---|---|---|
name*Field name, typed from your form values (supports paths such as `address.city`). | TName | — |
label*Visible label. | React.ReactNode | — |
descriptionHelp text under the control. It stays readable to screen readers when an error is shown. | React.ReactNode | — |
requiredShow the required asterisk. Pair it with a schema rule. | boolean | — |
orientationLabel and control layout. `horizontal` suits checkboxes and switches. | FieldProps["orientation"] | — |
rulesBuilt-in validation rules (`required`, `minLength`, `pattern`, `validate`…). Not needed when you use a schema resolver. | ControllerProps<TValues, TName>["rules"] | — |
formOptional form object; defaults to the nearest `<Form>`. | UseFormReturn<TValues> | — |
render*Renders the control. Spread the props onto it: `render={(props) => <Input {...props} />}`. For checkboxes or switches map `props.value` to `checked` and `props.onChange` to `onCheckedChange`. | (props: FormControlProps<TValues, TName>) => React.ReactNode | — |
className | string | — |
<FormSubmit>
| Prop | Type | Default |
|---|---|---|
pendingTextText shown on the button while the submit handler is running. | React.ReactNode | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter | Submits; focus moves to the first invalid control |
| Screen readers | Labels, hints and errors are linked; errors use role alert |
Use with AI
const form = useForm(); <Form form onSubmit><FormField name label render={(props) => <Input {...props}/>}/><FormSubmit/></Form>. Use rules or a resolver for validation. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Form (@ballmac/form) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Any form with client validation
- Forms that need per-field errors announced
Not for
- A single uncontrolled input; use field
- Server actions without client state
Registry JSON: https://ui.ballmac.com/r/form.json
Credits
Based on shadcn/ui Form, adapted by Ballmac. Free to use in personal and commercial projects.
Pairs well with
Field
Form field layout with label, description and error that wire their ids to the control automatically, plus fieldset, legend, orientation and invalid/disabled state.
Input
A text input in three heights that match Button, plus InputGroup and InputGroupAddon for leading or trailing icons, units and domains.
Checkbox
A Radix checkbox with checked and indeterminate (mixed) states, a check or minus icon, focus ring and invalid styling, for forms and select-all lists.
Select
A Radix select with a sized trigger, popper-positioned menu, scroll buttons, groups, labels, separators and a check indicator on the chosen item.