@cf/ui

@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

PropTypeDefault
Field orientationvertical | horizontal | responsivevertical
FieldLegend variantlegend | labellegend
FieldError errorsArray<{ message?: string } | undefined>

Rules

The same rules ship in DESIGN.md and the MCP server, for the agents building with this.

  • must

    Give every Field a FieldLabel with htmlFor matching the control's id; FieldTitle is a visual heading and doesn't name anything.

    label-with-html-for

  • must

    When a field is invalid, set data-invalid on Field and aria-invalid on the control together, and put the message in FieldError.

    data-invalid only colours the group; aria-invalid is what assistive tech and the control's own styles read.

    invalid-on-both

  • must

    Wrap fields in FieldGroup when using orientation="responsive", because it switches on the @container/field-group query.

    responsive-needs-group

  • should

    Use FieldSet with a FieldLegend for a group of radios, checkboxes or switches that answer one question, not a FieldGroup with a heading.

    fieldset-for-choice-groups

  • should

    Use orientation="horizontal" for a Switch or checkbox beside its label, with the text in FieldContent.

    horizontal-for-toggles

Install

bunx shadcn@latest add @cf/field

Needs the @cf registry in your components.json once. The theme and any sibling components come along automatically.

Source

field.tsxShow
"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,
};