Form
A form system without react-hook-form: useForm with sync and async validators, native constraint fallback, server error mapping, a focusable error summary, pending submit and a multi-step variant.
Preview
Create your account
Free for teams up to five.
Set up your workspace
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/form.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| useForm({ initialValues }) | V | — | Initial values; their keys are the field names. |
| useForm({ validators }) | { [name]: Validator | Validator[] } | — | (value, values) => message | undefined, sync or async; run in order. Native constraints are the fallback. |
| useForm({ onSubmit }) | (values) => void | { errors?, formError? } | — | Runs after every field passes; may be async. Returned errors map onto fields or the summary. |
| useForm({ labels }) | { [name]: string } | field name | Human names used in the error summary links. |
| Form form | FormApi | — | The object returned by useForm. Renders a noValidate <form>. |
| Field name | string | — | Connects the label, control, description and error to one value. |
| FieldControl | single child | — | Injects id, name, value/checked, onChange, onBlur, aria-invalid, aria-describedby and aria-busy. |
| FieldError children | ReactNode | field error | Overrides the message; reveals with a short slide. |
| FormErrorSummary title | string | "Fix N fields to continue" | Shown after a failed submit; focuses itself and links to each field. |
| FormSubmit | ButtonProps | — | Submit button that shows a spinner while submitting. |
| FormSteps steps | { title, fields, content }[] | — | Multi-step layout; Next validates only that step's fields. |
| FormSteps submitLabel / step / onStepChange | ReactNode · number · (step) => void | "Submit" | Last-step button label and optional controlled step. |
Build notes
- Validators run in sequence; each async run increments a per-field counter
in a ref, and a result is dropped if a newer run started (no stale
"taken" after the user changed the name).
- The form element is held in state through a callback ref (not a ref
object) so the API object can be read during render under the React
Compiler lint rules; native validation reads
`form.elements.namedItem(name).checkValidity()` in handlers only.
- `FieldControl` is a Radix `Slot`: boolean values map to `checked`,
others to `value`; Radix Checkbox can be wired by passing
`onCheckedChange` that calls `form.setValue`.
- `FieldDescription` registers itself with its `Field` in an effect so
`aria-describedby` only names ids that exist.
- `FormSteps` uses `AnimatePresence mode="popLayout"` with direction
variants (x ±24 px, house spring, 160 ms fade); headings carry
`data-step-heading={index}` so focus goes to the entering copy, not the
exiting one.
<!-- PEBBLE-LICENSE -->View source
registry/input/form/form.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: form.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THIRD-PARTY NOTICES
These notices apply to identified upstream portions; Pebble terms do not replace or narrow their rights.
Upstream: https://raw.githubusercontent.com/shadcn-ui/ui/main/LICENSE.md
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button, type ButtonProps } from "@/registry/button/button/button";
import { Label, type LabelProps } from "@/registry/input/label/label";
/* -------------------------------------------------------------------------- */
/* useForm: values, touched, sync + async validation, server errors */
/* -------------------------------------------------------------------------- */
export type FormValues = Record<string, unknown>;
type MaybePromise<T> = T | Promise<T>;
/** Returns an error message, or nothing when the value is fine. */
export type Validator<V extends FormValues> = (
value: V[keyof V],
values: V,
) => MaybePromise<string | undefined | null | false>;
export type FormErrors<V extends FormValues> = Partial<Record<keyof V & string, string>>;
/** What `onSubmit` may return to map server-side problems back onto fields. */
export type SubmitResult<V extends FormValues> = void | {
errors?: FormErrors<V>;
formError?: string;
};
export type UseFormOptions<V extends FormValues> = {
initialValues: V;
validators?: Partial<Record<keyof V & string, Validator<V> | Validator<V>[]>>;
onSubmit: (values: V) => MaybePromise<SubmitResult<V>>;
/** Field labels used in the error summary. Defaults to the field name. */
labels?: Partial<Record<keyof V & string, string>>;
};
export type FormApi<V extends FormValues> = ReturnType<typeof useForm<V>>;
export function useForm<V extends FormValues>({
initialValues,
validators = {},
onSubmit,
labels = {},
}: UseFormOptions<V>) {
type Name = keyof V & string;
const [values, setValues] = React.useState<V>(initialValues);
const [errors, setErrorsState] = React.useState<FormErrors<V>>({});
const [touched, setTouched] = React.useState<Partial<Record<Name, boolean>>>({});
const [validating, setValidating] = React.useState<Partial<Record<Name, boolean>>>({});
const [submitting, setSubmitting] = React.useState(false);
const [submitCount, setSubmitCount] = React.useState(0);
const [formError, setFormError] = React.useState<string | undefined>();
// Bumped on each submit that fails validation, so the summary can take focus.
const [failedAttempt, setFailedAttempt] = React.useState(0);
// Kept in state (set by <Form>'s callback ref) so the API object holds no refs.
const [formElement, attach] = React.useState<HTMLFormElement | null>(null);
// Per-field run counters: a slow async result is dropped if a newer run started.
const runs = React.useRef<Record<string, number>>({});
const setError = (name: Name, message: string | undefined) =>
setErrorsState((prev) => {
if (prev[name] === message) return prev;
const next = { ...prev };
if (message) next[name] = message;
else delete next[name];
return next;
});
/** Native constraint validation fallback (required, type=email, pattern, min…). */
const nativeMessage = (name: Name) => {
const el = formElement?.elements.namedItem(name);
if (
el instanceof HTMLInputElement ||
el instanceof HTMLSelectElement ||
el instanceof HTMLTextAreaElement
) {
const control = el;
return control.checkValidity() ? undefined : control.validationMessage || "Check this field.";
}
return undefined;
};
const validateField = async (name: Name, all: V = values): Promise<string | undefined> => {
const run = (runs.current[name] = (runs.current[name] ?? 0) + 1);
const list = validators[name];
const fns = list ? (Array.isArray(list) ? list : [list]) : [];
let message: string | undefined;
for (const fn of fns) {
const out = fn(all[name], all);
if (out instanceof Promise) {
setValidating((p) => ({ ...p, [name]: true }));
const resolved = await out;
if (runs.current[name] !== run) return undefined;
setValidating((p) => ({ ...p, [name]: false }));
if (resolved) message = resolved;
} else if (out) {
message = out;
}
if (message) break;
}
if (!message) message = nativeMessage(name);
if (runs.current[name] === run) setError(name, message);
return message;
};
/** Validates the named fields (all when omitted); true when they all pass. */
const validateFields = async (names?: Name[]) => {
const list = names ?? (Object.keys(values) as Name[]);
setTouched((p) => ({ ...p, ...Object.fromEntries(list.map((n) => [n, true])) }));
const results = await Promise.all(list.map((n) => validateField(n)));
return results.every((r) => !r);
};
const setValue = <K extends Name>(name: K, value: V[K]) => {
const next = { ...values, [name]: value };
setValues(next);
// Reward early: once a field was left or has an error, re-check as you type.
if (touched[name] || errors[name]) void validateField(name, next);
};
const blur = (name: Name) => {
setTouched((p) => ({ ...p, [name]: true }));
void validateField(name);
};
const handleSubmit = async (event?: React.FormEvent) => {
event?.preventDefault();
if (submitting) return;
setSubmitCount((c) => c + 1);
setFormError(undefined);
const ok = await validateFields();
if (!ok) {
setFailedAttempt((n) => n + 1);
return;
}
setSubmitting(true);
try {
const result = await onSubmit(values);
if (result?.errors) {
setErrorsState((p) => ({ ...p, ...result.errors }));
setFailedAttempt((n) => n + 1);
}
if (result?.formError) setFormError(result.formError);
} finally {
setSubmitting(false);
}
};
const reset = (next: V = initialValues) => {
setValues(next);
setErrorsState({});
setTouched({});
setSubmitCount(0);
setFormError(undefined);
};
return {
values,
errors,
touched,
validating,
submitting,
submitCount,
formError,
failedAttempt,
labels: labels as Partial<Record<string, string>>,
formElement,
attach,
setValue,
setError,
setErrors: (next: FormErrors<V>) => setErrorsState(next),
blur,
validateField,
validateFields,
handleSubmit,
reset,
};
}
/* -------------------------------------------------------------------------- */
/* Context */
/* -------------------------------------------------------------------------- */
// The context is untyped on purpose: fields address values by string name.
type AnyForm = FormApi<FormValues>;
const FormContext = React.createContext<AnyForm | null>(null);
export function useFormContext() {
const form = React.useContext(FormContext);
if (!form) throw new Error("Form parts must be used inside <Form>.");
return form;
}
type FieldContextValue = {
name: string;
controlId: string;
descriptionId: string;
errorId: string;
error: string | undefined;
validating: boolean;
hasDescription: boolean;
registerDescription: (on: boolean) => void;
};
const FieldContext = React.createContext<FieldContextValue | null>(null);
export function useField() {
const field = React.useContext(FieldContext);
if (!field) throw new Error("Field parts must be used inside <Field>.");
return field;
}
/* -------------------------------------------------------------------------- */
/* Form */
/* -------------------------------------------------------------------------- */
export type FormProps<V extends FormValues> = Omit<React.ComponentProps<"form">, "onSubmit"> & {
form: FormApi<V>;
};
export function Form<V extends FormValues>({ form, className, children, ...props }: FormProps<V>) {
return (
<FormContext.Provider value={form as unknown as AnyForm}>
<MotionConfig reducedMotion="user">
<form
ref={(el) => form.attach(el)}
data-slot="form"
noValidate
aria-busy={form.submitting || undefined}
onSubmit={form.handleSubmit}
className={cn("flex flex-col gap-5", className)}
{...props}
>
{children}
</form>
</MotionConfig>
</FormContext.Provider>
);
}
/* -------------------------------------------------------------------------- */
/* Field parts */
/* -------------------------------------------------------------------------- */
export function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="field-group" className={cn("flex flex-col gap-4", className)} {...props} />
);
}
export type FieldProps = React.ComponentProps<"div"> & { name: string };
export function Field({ name, className, ...props }: FieldProps) {
const form = useFormContext();
const id = React.useId();
const [hasDescription, setHasDescription] = React.useState(false);
const error = form.errors[name];
const value: FieldContextValue = {
name,
controlId: `${id}-control`,
descriptionId: `${id}-description`,
errorId: `${id}-error`,
error,
validating: !!form.validating[name],
hasDescription,
registerDescription: setHasDescription,
};
return (
<FieldContext.Provider value={value}>
<div
data-slot="field"
data-invalid={error ? "true" : undefined}
data-validating={value.validating || undefined}
className={cn("group/field flex flex-col gap-2", className)}
{...props}
/>
</FieldContext.Provider>
);
}
export function FieldLabel({ className, ...props }: LabelProps) {
const field = useField();
return (
<Label
data-slot="field-label"
htmlFor={field.controlId}
className={cn("group-data-[invalid=true]/field:text-danger-strong", className)}
{...props}
/>
);
}
export function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
const field = useField();
const { registerDescription } = field;
React.useEffect(() => {
registerDescription(true);
return () => registerDescription(false);
}, [registerDescription]);
return (
<p
id={field.descriptionId}
data-slot="field-description"
className={cn("text-xs leading-normal text-muted-foreground", className)}
{...props}
/>
);
}
/**
* Wires its single child to the field: id, name, value, change and blur,
* plus `aria-invalid` and `aria-describedby`. Works with native inputs,
* TextField, Textarea and anything forwarding those props.
*/
export function FieldControl({ children, ...props }: React.ComponentProps<typeof Slot.Root>) {
const form = useFormContext();
const field = useField();
const value = form.values[field.name];
const describedBy =
[field.hasDescription && field.descriptionId, field.error && field.errorId]
.filter(Boolean)
.join(" ") || undefined;
const isBoolean = typeof value === "boolean";
// Slot is typed as a generic HTMLElement; these are input props.
const controlProps: Record<string, unknown> = {
name: field.name,
...(isBoolean ? { checked: value } : { value: (value as string | number | undefined) ?? "" }),
};
return (
<Slot.Root
id={field.controlId}
{...controlProps}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
form.setValue(field.name, isBoolean ? e.target.checked : e.target.value)
}
onBlur={() => form.blur(field.name)}
aria-invalid={field.error ? true : undefined}
aria-describedby={describedBy}
aria-busy={field.validating || undefined}
{...props}
>
{children}
</Slot.Root>
);
}
function AlertIcon() {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
aria-hidden
className="mt-px size-3.5 shrink-0"
>
<circle cx="8" cy="8" r="6" />
<path d="M8 5v3.5M8 11h.01" />
</svg>
);
}
/** The field's error, revealed with a short height + fade when it appears. */
export function FieldError({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) {
const field = useField();
const message = children ?? field.error;
return (
<AnimatePresence initial={false}>
{message && (
<motion.p
key="error"
id={field.errorId}
data-slot="field-error"
initial={{ opacity: 0, height: 0, y: -4 }}
animate={{ opacity: 1, height: "auto", y: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className={cn(
"-mt-0.5 flex gap-1.5 overflow-hidden text-xs text-danger-strong",
className,
)}
>
<AlertIcon />
<span>{message}</span>
</motion.p>
)}
</AnimatePresence>
);
}
/* -------------------------------------------------------------------------- */
/* Error summary */
/* -------------------------------------------------------------------------- */
/**
* After a failed submit, lists every error as a link that focuses its field.
* Takes focus itself so screen readers hear the count first.
*/
export function FormErrorSummary({ className, title }: { className?: string; title?: string }) {
const form = useFormContext();
const ref = React.useRef<HTMLDivElement>(null);
const entries = Object.entries(form.errors).filter((e): e is [string, string] => !!e[1]);
const show = (form.submitCount > 0 && entries.length > 0) || !!form.formError;
React.useEffect(() => {
if (form.failedAttempt > 0) ref.current?.focus();
}, [form.failedAttempt]);
const focusField = (name: string) => {
const el = form.formElement?.elements.namedItem(name);
if (el instanceof HTMLElement) {
el.focus();
el.scrollIntoView?.({ block: "center", behavior: "smooth" });
}
};
return (
<AnimatePresence initial={false}>
{show && (
<motion.div
ref={ref}
key="summary"
tabIndex={-1}
role="alert"
data-slot="form-error-summary"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className={cn(
"rounded-lg bg-danger/6 p-3 text-[13px] ring-1 ring-danger/25 outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
<p className="flex items-center gap-1.5 font-medium text-danger-strong">
<AlertIcon />
{form.formError ??
title ??
`Fix ${entries.length} ${entries.length === 1 ? "field" : "fields"} to continue`}
</p>
{entries.length > 0 && (
<ul className="mt-1.5 flex flex-col gap-0.5 ps-5">
{entries.map(([name, message]) => (
<li key={name}>
<a
href={`#${name}`}
onClick={(e) => {
e.preventDefault();
focusField(name);
}}
className="text-foreground underline decoration-danger/40 underline-offset-2 hover:decoration-danger"
>
{form.labels[name] ?? name}
</a>
<span className="text-muted-foreground">: {message}</span>
</li>
))}
</ul>
)}
</motion.div>
)}
</AnimatePresence>
);
}
/* -------------------------------------------------------------------------- */
/* Submit */
/* -------------------------------------------------------------------------- */
export function FormSubmit({ children, ...props }: ButtonProps) {
const form = useFormContext();
return (
<Button type="submit" loading={form.submitting} data-slot="form-submit" {...props}>
{children}
</Button>
);
}
/* -------------------------------------------------------------------------- */
/* Multi-step */
/* -------------------------------------------------------------------------- */
export type FormStep = {
title: string;
/** Fields validated before moving past this step. */
fields: string[];
content: React.ReactNode;
};
export type FormStepsProps = {
steps: FormStep[];
submitLabel?: React.ReactNode;
className?: string;
/** Controlled step index. */
step?: number;
onStepChange?: (step: number) => void;
};
function CheckGlyph() {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="size-3"
>
<path d="M3.5 8.5 6.5 11.5l6-7" />
</svg>
);
}
/**
* Step header, one visible step at a time (slides with the direction of
* travel), and Back / Next / Submit. Next validates only that step's fields.
*/
export function FormSteps({
steps,
submitLabel = "Submit",
className,
step: stepProp,
onStepChange,
}: FormStepsProps) {
const form = useFormContext();
const [stepState, setStepState] = React.useState(0);
const [direction, setDirection] = React.useState(1);
const [checking, setChecking] = React.useState(false);
const step = Math.min(stepProp ?? stepState, steps.length - 1);
const last = step === steps.length - 1;
const headingId = React.useId();
const panelRef = React.useRef<HTMLDivElement>(null);
const moved = React.useRef(false);
const go = (next: number) => {
setDirection(next > step ? 1 : -1);
moved.current = true;
setStepState(next);
onStepChange?.(next);
};
React.useEffect(() => {
// After a step change, move focus to the new step's heading.
if (!moved.current) return;
moved.current = false;
panelRef.current?.querySelector<HTMLElement>(`[data-step-heading="${step}"]`)?.focus();
}, [step]);
const next = async () => {
setChecking(true);
const ok = await form.validateFields(steps[step].fields);
setChecking(false);
if (ok) go(step + 1);
};
return (
<div data-slot="form-steps" className={cn("flex flex-col gap-5", className)}>
<ol data-slot="form-steps-header" aria-label="Progress" className="flex items-center gap-2">
{steps.map((s, i) => {
const done = i < step;
const current = i === step;
return (
<li
key={s.title}
aria-current={current ? "step" : undefined}
className="flex min-w-0 flex-1 flex-col gap-2"
>
{/* A track per step that fills ember as you reach it. */}
<span
aria-hidden
className="relative h-1 overflow-hidden rounded-full bg-muted inset-shadow-xs"
>
<motion.span
className="absolute inset-y-0 start-0 rounded-full bg-accent"
initial={false}
animate={{ width: done ? "100%" : current ? "50%" : "0%" }}
transition={{ type: "spring", stiffness: 420, damping: 32, mass: 0.8 }}
/>
</span>
<span className="flex min-w-0 items-center gap-1.5 text-xs">
<span
aria-hidden
className={cn(
"grid size-4 shrink-0 place-items-center rounded-full font-mono text-[10px] tabular-nums",
done &&
"bg-linear-to-b from-key-top to-key-bottom text-primary-foreground ring-1 ring-key-edge",
current && "text-accent-strong ring-1 ring-accent",
!done && !current && "text-muted-foreground ring-1 ring-border",
)}
>
{done ? <CheckGlyph /> : i + 1}
</span>
<span
className={cn(
"truncate",
current ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
{s.title}
</span>
<span className="sr-only">
{done ? " (done)" : current ? " (current step)" : ""}
</span>
</span>
</li>
);
})}
</ol>
<div ref={panelRef} className="relative -m-1 overflow-hidden p-1">
<AnimatePresence initial={false} mode="popLayout" custom={direction}>
<motion.section
key={step}
custom={direction}
aria-labelledby={headingId}
variants={{
enter: (d: number) => ({ opacity: 0, x: d * 24 }),
center: { opacity: 1, x: 0 },
exit: (d: number) => ({ opacity: 0, x: d * -24 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "spring", stiffness: 420, damping: 32, mass: 0.8 },
opacity: { duration: 0.16 },
}}
className="flex flex-col gap-4"
onKeyDown={(e) => {
// Enter in a field moves to the next step (validated) instead
// of submitting the whole form early.
const target = e.target as HTMLElement;
if (e.key === "Enter" && !last && target.tagName === "INPUT") {
e.preventDefault();
void next();
}
}}
>
<h3
id={headingId}
data-step-heading={step}
tabIndex={-1}
className="text-sm font-medium tracking-[-0.01em] outline-none"
>
<span className="me-1.5 font-mono text-xs text-muted-foreground tabular-nums">
{step + 1}/{steps.length}
</span>
{steps[step].title}
</h3>
{steps[step].content}
</motion.section>
</AnimatePresence>
</div>
<div data-slot="form-steps-footer" className="flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
onClick={() => go(step - 1)}
disabled={step === 0}
className={cn(step === 0 && "invisible")}
>
Back
</Button>
{last ? (
<FormSubmit>{submitLabel}</FormSubmit>
) : (
<Button type="button" variant="primary" loading={checking} onClick={next}>
Next
</Button>
)}
</div>
</div>
);
}
