Primitives
Form
Accessible form scaffolding for react-hook-form — binds the okibi field primitives to a schema, wires `aria-invalid`/`aria-describedby`, and surfaces validation as a mono system line.
● LIVE
"use client";import { useForm } from "react-hook-form";import { useTheme } from "next-themes";import { toast } from "sonner";import { Button } from "@/registry/okibi/ui/button";import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from "@/registry/okibi/ui/form";import { Input } from "@/registry/okibi/ui/input";import { Toaster } from "@/registry/okibi/ui/sonner";type FormValues = { callsign: string;};export default function FormDemo() { // Follow the docs theme so the submit toast matches the active mode. const { resolvedTheme } = useTheme(); const form = useForm<FormValues>({ defaultValues: { callsign: "" }, }); function onSubmit(values: FormValues) { toast("Operator registered", { description: `Callsign locked — ${values.callsign}`, }); } return ( <> <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="w-72 space-y-6"> <FormField control={form.control} name="callsign" rules={{ required: "Callsign is required.", minLength: { value: 2, message: "Callsign must be at least 2 characters.", }, maxLength: { value: 16, message: "Callsign must be 16 characters or fewer.", }, }} render={({ field }) => ( <FormItem> <FormLabel>Callsign</FormLabel> <FormControl> <Input placeholder="VANTA-7" {...field} /> </FormControl> <FormDescription> Your operator handle on the relay. </FormDescription> <FormMessage /> </FormItem> )} /> <Button type="submit" variant="signal"> Register </Button> </form> </Form> <Toaster theme={resolvedTheme === "light" ? "light" : "dark"} /> </> );}npx shadcn@latest add https://okibi.cndr.dev/r/form.jsonInstallation
Install the theme first, then add the component:
npx shadcn@latest add https://okibi.cndr.dev/r/form.jsonInstall the required dependencies:
npm install @radix-ui/react-slot react-hook-formAdd the okibi building blocks it composes: label.
Copy the source from the Code tab above into components/ui/form.tsx. It uses the cn helper and the okibi theme tokens — install the theme first if you haven't.
Usage
import { useForm } from "react-hook-form"
import { Input } from "@/components/ui/input"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
const form = useForm({ defaultValues: { callsign: "" } })
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="callsign"
rules={{ required: "Callsign is required." }}
render={({ field }) => (
<FormItem>
<FormLabel>Callsign</FormLabel>
<FormControl>
<Input placeholder="VANTA-7" {...field} />
</FormControl>
<FormDescription>Your operator handle on the relay.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>Anatomy
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="callsign"
render={({ field }) => (
<FormItem>
<FormLabel>Callsign</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Your operator handle.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>Form— the react-hook-formFormProvider.FormField— binds onename/controland exposesfieldvia render prop.FormItem— scopes the ids that wire the parts together.FormLabel— labels the control; turns destructive on error.FormControl— threadsaria-invalid/aria-describedbyinto the child input.FormDescription— the helper line, referenced byaria-describedby.FormMessage— therole='alert'validation line (renders nothing when valid).
Examples
Field types
FormField binds any control to the form. Wrap an Input, a Select, and a Checkbox in FormControl and each one inherits the field's id, error state, and aria-describedby — the whole input family composes through one wrapper.
● LIVE
"use client";import * as React from "react";import { useForm } from "react-hook-form";import { Button } from "@/registry/okibi/ui/button";import { Checkbox } from "@/registry/okibi/ui/checkbox";import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from "@/registry/okibi/ui/form";import { Input } from "@/registry/okibi/ui/input";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/registry/okibi/ui/select";type FormValues = { operator: string; sector: string; acknowledge: boolean;};export default function FormControlsDemo() { const [submitted, setSubmitted] = React.useState<FormValues | null>(null); const form = useForm<FormValues>({ defaultValues: { operator: "", sector: "", acknowledge: false }, }); return ( <Form {...form}> <form onSubmit={form.handleSubmit((values) => setSubmitted(values))} className="w-72 space-y-6" > <FormField control={form.control} name="operator" rules={{ required: "Operator ID is required.", minLength: { value: 2, message: "Operator ID is too short." }, }} render={({ field }) => ( <FormItem> <FormLabel>Operator ID</FormLabel> <FormControl> <Input placeholder="CH-0x4F" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="sector" rules={{ required: "Select a sector." }} render={({ field }) => ( <FormItem> <FormLabel>Sector</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl> <SelectTrigger className="w-full"> <SelectValue placeholder="Select sector" /> </SelectTrigger> </FormControl> <SelectContent> <SelectItem value="ALPHA">Alpha</SelectItem> <SelectItem value="BRAVO">Bravo</SelectItem> <SelectItem value="CHARLIE">Charlie</SelectItem> <SelectItem value="DELTA">Delta</SelectItem> </SelectContent> </Select> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="acknowledge" rules={{ required: "Protocol must be acknowledged." }} render={({ field }) => ( <FormItem className="flex flex-row items-start gap-3"> <FormControl> <Checkbox checked={field.value} onCheckedChange={field.onChange} /> </FormControl> <div className="space-y-1"> <FormLabel>Acknowledge protocol</FormLabel> <FormDescription> Confirm clearance before transmitting. </FormDescription> <FormMessage /> </div> </FormItem> )} /> <Button type="submit" variant="signal"> Transmit </Button> {submitted ? ( <pre className="border border-border bg-surface-2 p-3 font-mono text-xs text-muted-foreground"> {JSON.stringify(submitted, null, 2)} </pre> ) : null} </form> </Form> );}API Reference
FormField
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | – | Field name in the form values (react-hook-form). |
control | Control | – | The control object from useForm(). |
rules | RegisterOptions | – | Validation rules (required, min, pattern, validate…). |
render | ({ field, fieldState }) => ReactElement | – | Render prop wiring field to your control. |
Accessibility
FormControlthreadsaria-invalidand a context-awarearia-describedbyinto its child viaSlot— description id always, description + message ids when there's an error.FormItemscopes ids withuseId, derivingformItemId,formDescriptionId, andformMessageIdso label, control, description, and message all cross-reference.FormLabelwireshtmlFortoformItemIdand flips totext-destructive-spark(viadata-error) in lockstep with the field's error state.FormMessagecarriesrole='alert', so a validation error is announced live when it appears after a failed submit; it returnsnullwhen empty.- Error state propagates one
aria-invalidto any okibi input through the singleFormControlwrapper, flipping it to its destructive keyline.
Guidelines
Do
- Wrap each control in
FormControlso it inheritsaria-invalidandaria-describedbyautomatically. - Put helper text in
FormDescriptionand always includeFormMessageto surface validation errors. - Define validation with the field
rules(required, pattern, validate) so messages flow through automatically.
Don't
- Don't hand-roll
aria-describedby/aria-invalidon inputs inside a field —FormControlalready wires them. - Don't render error text outside
FormMessage; you'd lose therole='alert'live announcement. - Don't use
FormFieldparts outside aFormprovider —useFormFieldwill throw.