PixelDosa
core

Field

A composable form-field wrapper — Field, FieldLabel, FieldControl, FieldDescription and FieldError — that wires ARIA automatically onto whatever control you place inside it.

We'll only use this to send order updates.

Overview
View code

Installation

bash
npx shadcn@latest add @pixeldosa/field

FieldControl clones its single child rather than rendering a wrapper element, so pass exactly one React element (an <input>, <textarea>, or a component that forwards id/aria-invalid/aria-describedby/ref to a real form control) as its child.

Usage

import { Field, FieldControl, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";

export function EmailField({ value, onChange, error }: Props) {
  return (
    <Field invalid={Boolean(error)}>
      <FieldLabel>Email</FieldLabel>
      <FieldControl>
        <input type="email" value={value} onChange={onChange} className="…" />
      </FieldControl>
      <FieldDescription>We'll only use this to send order updates.</FieldDescription>
      <FieldError>{error}</FieldError>
    </Field>
  );
}

FieldControl clones its single child, so it accepts exactly one React element — a native <input>/<textarea>, or any component that forwards id, aria-invalid, aria-describedby and ref to a real form control.

FieldError renders nothing when its children are falsy, so you can pass a validation message straight through without a conditional wrapper.

Props

Field

PropTypeDefaultDescription
idstringgeneratedBase id for the control. Descendants derive -description and -error ids from it.
invalidbooleanfalseMarks the field invalid — flows into aria-invalid on the control.

FieldControl

Takes exactly one child element. No other props.

FieldLabel, FieldDescription, FieldError

Standard label / p / p props. Must be rendered inside a Field.

Accessibility

  • The control receives aria-describedby combining the error and description ids automatically — you never write that string by hand.
  • FieldError uses role="alert" so a message appearing after the field already has focus (e.g. from async validation) is still announced.
  • aria-invalid is set on the control whenever Field's invalid prop is true, which most input styling (including this system's) hooks with aria-invalid: variants.

Engineering Notes

Built as a compound-component set sharing React context rather than a single Field component with many props, because form fields have a genuinely variable shape (some have descriptions, some don't; some controls are custom comboboxes, not <input>) and a large prop-driven API would force every consumer through the same limited combinations. FieldControl clones its child instead of rendering its own DOM node specifically so that input+icon or input+button compositions keep their existing layout — a wrapper div would break those. FieldDescription and FieldError register their presence into context via an effect so FieldControl's aria-describedby is always accurate even when a description or error mounts after the control, which happens constantly with async validation.

Motion Notes

No motion. This is a compound primitive other components render inside of, not a state that changes visibly on its own — anything Field itself animated would be inherited (and likely duplicated) by every component built on top of it. Error text appearing or disappearing is the one plausible candidate for a transition; it is left as a plain instantaneous swap here so that a consumer building a specific validation UX (e.g. GhostInput's inline error) chooses its own timing rather than inheriting one that might not fit.

Source

The exact file shadcn add writes into your project.

field.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

/**
 * Generates a stable id set (field/description/error) shared by every part below,
 * so consumers never have to invent or wire up ids themselves.
 */
function useFieldIds(providedId?: string) {
  const generatedId = React.useId();
  const id = providedId ?? generatedId;
  return {
    fieldId: id,
    descriptionId: `${id}-description`,
    errorId: `${id}-error`,
  };
}

type FieldContextValue = ReturnType<typeof useFieldIds> & {
  invalid: boolean;
  hasDescription: boolean;
  setHasDescription: (value: boolean) => void;
  hasError: boolean;
  setHasError: (value: boolean) => void;
};

const FieldContext = React.createContext<FieldContextValue | null>(null);

function useFieldContext() {
  const ctx = React.useContext(FieldContext);
  if (!ctx) {
    throw new Error("Field.* parts must be rendered inside <Field>.");
  }
  return ctx;
}

export interface FieldProps extends React.ComponentPropsWithoutRef<"div"> {
  /** Base id for the control. A stable id is generated if omitted. */
  id?: string;
  /** Marks the field as invalid — the control gets aria-invalid and the error styles apply. */
  invalid?: boolean;
}

/**
 * Composable form-field wrapper: label, control, description and error all share one
 * generated id set and wire their own `aria-describedby` / `aria-invalid` automatically.
 *
 * Deliberately has no opinion on form-state management — it works with plain
 * `useState`, react-hook-form, or anything else, because the pieces most AI-in-product
 * components (SmartField, GhostInput) compose on top of are these primitives, not a
 * specific form library's API.
 */
const Field = React.forwardRef<HTMLDivElement, FieldProps>(function Field(
  { id, invalid = false, className, children, ...props },
  ref
) {
  const ids = useFieldIds(id);
  const [hasDescription, setHasDescription] = React.useState(false);
  const [hasError, setHasError] = React.useState(false);

  const value = React.useMemo<FieldContextValue>(
    () => ({ ...ids, invalid, hasDescription, setHasDescription, hasError, setHasError }),
    [ids, invalid, hasDescription, hasError]
  );

  return (
    <FieldContext.Provider value={value}>
      <div ref={ref} className={cn("flex flex-col gap-1.5", className)} data-invalid={invalid || undefined} {...props}>
        {children}
      </div>
    </FieldContext.Provider>
  );
});

const FieldLabel = React.forwardRef<HTMLLabelElement, React.ComponentPropsWithoutRef<"label">>(
  function FieldLabel({ className, ...props }, ref) {
    const { fieldId } = useFieldContext();
    return (
      <label
        ref={ref}
        htmlFor={fieldId}
        className={cn(
          "text-sm font-medium text-foreground",
          "group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
          className
        )}
        {...props}
      />
    );
  }
);

/**
 * The interactive control. Clones the child element rather than rendering its own DOM
 * node so any control — a plain `<input>`, a `<textarea>`, a third-party combobox — can
 * be dropped in and still pick up the field's id and ARIA wiring without a wrapper div
 * changing its layout context (relevant for things like input+icon compositions).
 */
const FieldControl = React.forwardRef<HTMLElement, { children: React.ReactElement<Record<string, unknown>> }>(
  function FieldControl({ children }, forwardedRef) {
    const { fieldId, descriptionId, errorId, invalid, hasDescription, hasError } = useFieldContext();

    const describedBy = [hasError && errorId, hasDescription && descriptionId]
      .filter(Boolean)
      .join(" ");

    return React.cloneElement(children, {
      id: fieldId,
      "aria-invalid": invalid || undefined,
      "aria-describedby": describedBy || undefined,
      ref: forwardedRef,
    });
  }
);

const FieldDescription = React.forwardRef<HTMLParagraphElement, React.ComponentPropsWithoutRef<"p">>(
  function FieldDescription({ className, ...props }, ref) {
    const { descriptionId, setHasDescription } = useFieldContext();

    React.useEffect(() => {
      setHasDescription(true);
      return () => setHasDescription(false);
    }, [setHasDescription]);

    return (
      <p
        ref={ref}
        id={descriptionId}
        className={cn("text-sm text-muted-foreground", className)}
        {...props}
      />
    );
  }
);

/**
 * Renders nothing when there is no message — announced via `role="alert"` so assistive
 * tech picks up validation errors that appear after the field already had focus, which
 * `aria-describedby` alone does not guarantee.
 */
const FieldError = React.forwardRef<HTMLParagraphElement, React.ComponentPropsWithoutRef<"p">>(
  function FieldError({ className, children, ...props }, ref) {
    const { errorId, setHasError } = useFieldContext();

    React.useEffect(() => {
      setHasError(Boolean(children));
      return () => setHasError(false);
    }, [children, setHasError]);

    if (!children) return null;

    return (
      <p
        ref={ref}
        id={errorId}
        role="alert"
        className={cn("flex items-center gap-1.5 text-sm font-medium text-destructive", className)}
        {...props}
      >
        {children}
      </p>
    );
  }
);

export { Field, FieldLabel, FieldControl, FieldDescription, FieldError };