A form composition primitive that wires label, control, description, and error together via useId and aria-describedby, with the error row animating open and closed through grid-template-rows.
npx shadcn@latest add @paragon/form-field"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { CircleAlert } from "lucide-react";
import { cn } from "@/lib/utils";
interface FormFieldContextValue {
id: string;
descriptionId: string;
messageId: string;
error?: string;
required: boolean;
disabled: boolean;
hasDescription: boolean;
registerDescription: () => () => void;
}
const FormFieldContext = React.createContext<FormFieldContextValue | null>(
null,
);
/**
* Read the current field's wiring (ids, error, required, disabled) from
* context — for building custom controls that participate in a FormField.
*/
export function useFormField() {
const context = React.useContext(FormFieldContext);
if (!context) {
throw new Error("useFormField must be used within a <FormField>.");
}
return context;
}
export interface FormFieldProps extends React.ComponentProps<"div"> {
/** Current error message. Truthy = invalid; drives aria and the message. */
error?: string;
/** Marks the label with an asterisk and sets aria-required on the control. */
required?: boolean;
/** Dims the label and description. The control manages its own disabled. */
disabled?: boolean;
}
/**
* The composition primitive for consistent forms: wires a label, any control,
* a description, and an animated error message together via useId —
* htmlFor, aria-describedby, and aria-invalid all land on the right nodes
* without any manual id plumbing.
*/
export function FormField({
error,
required = false,
disabled = false,
className,
children,
...props
}: FormFieldProps) {
const id = React.useId();
const [descriptionCount, setDescriptionCount] = React.useState(0);
const registerDescription = React.useCallback(() => {
setDescriptionCount((count) => count + 1);
return () => setDescriptionCount((count) => count - 1);
}, []);
const context = React.useMemo<FormFieldContextValue>(
() => ({
id: `${id}-control`,
descriptionId: `${id}-description`,
messageId: `${id}-message`,
error,
required,
disabled,
hasDescription: descriptionCount > 0,
registerDescription,
}),
[id, error, required, disabled, descriptionCount, registerDescription],
);
return (
<FormFieldContext.Provider value={context}>
<div
data-slot="form-field"
data-invalid={error ? true : undefined}
data-disabled={disabled || undefined}
className={cn("group/field flex w-full flex-col", className)}
{...props}
>
{children}
</div>
</FormFieldContext.Provider>
);
}
export function FormLabel({
className,
children,
...props
}: React.ComponentProps<"label">) {
const { id, error, required, disabled } = useFormField();
return (
<label
data-slot="form-label"
htmlFor={id}
className={cn(
"mb-1.5 flex items-baseline gap-1 text-sm font-medium text-foreground",
"transition-[color] duration-150 ease-out",
error && "text-destructive",
disabled && "opacity-50",
className,
)}
{...props}
>
{children}
{required && (
<span aria-hidden className="text-destructive">
*
</span>
)}
</label>
);
}
/**
* Slot that injects id, aria-describedby, aria-invalid, and aria-required
* onto whatever single control it wraps — a native input, a textarea, or a
* custom component that forwards props.
*/
export function FormControl(props: React.ComponentProps<typeof Slot>) {
const { id, error, required, descriptionId, messageId, hasDescription } =
useFormField();
return (
<Slot
data-slot="form-control"
id={id}
aria-invalid={error ? true : undefined}
aria-required={required || undefined}
aria-describedby={
cn(hasDescription && descriptionId, error && messageId) || undefined
}
{...props}
/>
);
}
export function FormDescription({
className,
...props
}: React.ComponentProps<"p">) {
const { descriptionId, registerDescription, disabled } = useFormField();
// Register so FormControl only references this id when it actually exists.
React.useEffect(() => registerDescription(), [registerDescription]);
return (
<p
data-slot="form-description"
id={descriptionId}
className={cn(
"mt-1.5 text-[13px] text-muted-foreground",
disabled && "opacity-50",
className,
)}
{...props}
/>
);
}
export interface FormMessageProps extends React.ComponentProps<"div"> {
/** Overrides the context error as the rendered message. */
children?: React.ReactNode;
}
/**
* The animated error region. Always mounted, so the collapse can animate:
* the row expands via grid-template-rows 0fr -> 1fr while the text fades and
* slides in; on clear it collapses with the exit ease while the last message
* is kept visible so it never pops out mid-animation.
*/
export function FormMessage({ className, children, ...props }: FormMessageProps) {
const { messageId, error } = useFormField();
const content = children ?? error;
const open = Boolean(content);
// Keep the previous message rendered while the row collapses.
const lastContent = React.useRef<React.ReactNode>(null);
React.useEffect(() => {
if (content) lastContent.current = content;
}, [content]);
const display = content ?? lastContent.current;
return (
<div
data-slot="form-message"
data-state={open ? "open" : "closed"}
aria-hidden={!open || undefined}
className={cn(
"grid transition-[grid-template-rows] motion-reduce:transition-none",
className,
)}
style={{
gridTemplateRows: open ? "1fr" : "0fr",
transitionDuration: open ? "200ms" : "150ms",
transitionTimingFunction: open ? "var(--ease-out)" : "var(--ease-exit)",
}}
{...props}
>
<div className="min-h-0 overflow-hidden">
<p
id={messageId}
role={open ? "alert" : undefined}
className={cn(
"flex items-start gap-1.5 pt-1.5 text-[13px] text-destructive",
"transition-[opacity,translate,filter] motion-reduce:transition-[opacity]",
open
? "translate-y-0 opacity-100 blur-none duration-200 ease-[var(--ease-out)]"
: "-translate-y-1 opacity-0 blur-[2px] duration-150 ease-[var(--ease-exit)]",
)}
>
<CircleAlert aria-hidden className="mt-px size-3.5 shrink-0" />
<span className="min-w-0">{display}</span>
</p>
</div>
</div>
);
}