Forms
Field
Label, control, description and animated error, with fieldsets and choice cards. Works with any form library.
pnpm dlx shadcn@latest add @paragon/fieldBrings in@paragon/label@paragon/separatorclass-variance-authoritylucide-reactcn
Settings layout
orientation="responsive" puts the label beside the control once the group is wide enough.
Shown to everyone in your institution.
Set at provisioning. Contact us to move regions.
Available on the Institution plan.
registry/paragon/ui/field.tsx
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { CircleAlert } from "lucide-react"
import { cn } from "cn"
import { Label } from "@/registry/paragon/ui/label"
import { Separator } from "@/registry/paragon/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-5 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-lg data-[variant=legend]:tracking-[-0.015em]",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-6 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva("group/field flex w-full gap-2", {
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
})
/**
* Label, control, description and error for one value. Set `data-invalid`
* on the Field and `aria-invalid` on the control; the label stays ink, only
* the control border and the error turn red.
*/
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn("group/field-content flex flex-1 flex-col gap-1 leading-snug", className)}
{...props}
/>
)
}
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 [&>[data-slot=field-hint]]:font-normal [&>[data-slot=field-hint]]:text-muted-foreground",
// A Field nested in a label becomes a selectable choice card.
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-card has-[>[data-slot=field]]:border has-[>[data-slot=field]]:transition-colors has-[>[data-slot=field]]:duration-(--duration-base) has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:border-foreground/35 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-4 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/15 *:data-[slot=field]:p-4",
"has-data-checked:border-foreground/50 has-data-checked:bg-card",
className
)}
{...props}
/>
)
}
/** Muted trailing text inside a FieldLabel, such as "Optional". */
function FieldHint({ className, ...props }: React.ComponentProps<"span">) {
return <span data-slot="field-hint" className={cn("text-sm", className)} {...props} />
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-start text-caption font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-foreground",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn("relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", className)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
/** Fades in under the control. Pass `children`, or `errors` from a form library. */
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ms-4 flex list-disc flex-col gap-1">
{uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn(
"flex animate-in items-start gap-1.5 text-caption font-normal text-destructive duration-(--duration-base) ease-paragon fade-in slide-in-from-top-1 motion-reduce:animate-none",
className
)}
{...props}
>
<CircleAlert aria-hidden="true" className="mt-[0.2em] size-3.5 shrink-0" />
<div className="min-w-0">{content}</div>
</div>
)
}
export {
Field,
FieldLabel,
FieldHint,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}