@cf/field
Field
The layout set for form fields, labels, descriptions, errors, fieldsets and groups, with vertical, horizontal and responsive orientations.
Only used to reply to you.
Finish the address and the error goes. The invalid state is driven by hand: data-invalid on Field, aria-invalid on the input.
Only used to reply to you.
Agents can read this note.
Anyone with the link can read it.
Stock shadcn base-nova, built from plain elements rather than Base UI's Field, so it doesn't track validity itself. Field is a role="group" div that styles its children through data-slot selectors, FieldLabel is Label with extra states (a label that wraps a whole Field becomes a selectable card), and FieldError takes either children or an errors array, dropping duplicate messages.
This is what the contact form and the note dialogs use. form is an older Base UI wrapper with the same export names; don't mix the two in one file. One quirk carried over from upstream: FieldTitle also sets data-slot="field-label".
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
<Field data-invalid={invalid || undefined}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input aria-invalid={invalid} id="email" type="email" />
<FieldDescription>Only used to reply to you.</FieldDescription>
</Field>Props
| Prop | Type | Default |
|---|---|---|
| Field orientation | vertical | horizontal | responsive | vertical |
| FieldLegend variant | legend | label | legend |
| FieldError errors | Array<{ message?: string } | undefined> | — |
Rules
The same rules ship in DESIGN.md and the MCP server, for the agents building with this.
- must
Give every
FieldaFieldLabelwithhtmlFormatching the control'sid;FieldTitleis a visual heading and doesn't name anything.label-with-html-for
- must
When a field is invalid, set
data-invalidonFieldandaria-invalidon the control together, and put the message inFieldError.data-invalidonly colours the group;aria-invalidis what assistive tech and the control's own styles read.invalid-on-both
- must
Wrap fields in
FieldGroupwhen usingorientation="responsive", because it switches on the@container/field-groupquery.responsive-needs-group
- should
Use
FieldSetwith aFieldLegendfor a group of radios, checkboxes or switches that answer one question, not aFieldGroupwith a heading.fieldset-for-choice-groups
- should
Use
orientation="horizontal"for a Switch or checkbox beside its label, with the text inFieldContent.horizontal-for-toggles
Install
bunx shadcn@latest add @cf/fieldNeeds the @cf registry in your components.json once. The theme and any sibling components come along automatically.
Source
field.tsxShowHide
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import { useMemo } from "react";
import { Label } from "./label";
import { Separator } from "./separator";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
data-slot="field-set"
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
data-slot="field-legend"
data-variant={variant}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
data-slot="field-group"
{...props}
/>
);
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
defaultVariants: {
orientation: "vertical",
},
variants: {
orientation: {
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:
"@md/field-group:flex-row flex-col @md/field-group:items-center *:w-full @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",
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
},
},
}
);
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
className={cn(fieldVariants({ orientation }), className)}
data-orientation={orientation}
data-slot="field"
role="group"
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
data-slot="field-content"
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 *:data-[slot=field]:p-2.5 group-data-[disabled=true]/field:opacity-50 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
data-slot="field-label"
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex w-fit items-center gap-2 font-medium text-sm group-data-[disabled=true]/field:opacity-50",
className
)}
data-slot="field-label"
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
className={cn(
"text-left font-normal text-muted-foreground text-sm leading-normal group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"nth-last-2:-mt-1 last:mt-0",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
data-slot="field-description"
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
data-content={!!children}
data-slot="field-separator"
{...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>
);
}
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="ml-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
className={cn("font-normal text-destructive text-sm", className)}
data-slot="field-error"
role="alert"
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
};