← All components

Field

A label, a control, its help and its error — wired to each other without writing an id.

Shipping address

Where the printed proofs should go.

The idea

The wiring nobody remembers to do by hand is done here: the label's `for`, the control's id, `aria-describedby` to the help and the error, `aria-errormessage` and `aria-invalid` are generated and handed to whatever control sits inside. The help and error register as they render, so a control never points at an id that is not on the page. The error box stays mounted as a polite live region, so a reason is heard when it arrives, and it folds open on expo-out — anything that shoves the form down must settle, not bounce. FieldSet is a real fieldset and legend, with no box: a legend and a gap already say these belong together.

Composes Input and NativeSelect and Button.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Field — usage20 lines
import { FieldSet, Field, Label, Input, FieldDescription, FieldError, useFieldControl } from "mihirmodi-ui";

<FieldSet legend="Shipping address" description="Where the proofs should go.">
  <Field>
    <Label>Full name</Label>
    <Input name="name" autoComplete="name" />
    <FieldDescription>Who the courier should ask for.</FieldDescription>
    <FieldError>{errors.name}</FieldError>
  </Field>
  <Field>
    <Label>Postcode</Label>
    <Input name="postcode" />
    {/* A validator's list works too; each message shows once. */}
    <FieldError errors={issues.postcode} />
  </Field>
</FieldSet>

{/* Your own control joins a Field the same way */}
const attrs = useFieldControl(props);
<button role="switch" {...attrs} />

Props

PropNotes
Field invalidbooleanLeft unset, the field is wrong whenever its FieldError has something to say.
Field disabled / requiredbooleanHanded to the control inside.
Field orientation"vertical" | "horizontal"Horizontal puts the label beside the control — for a switch. Wrap label and help in FieldContent.
Label asideReactNodeRight of the label: “Optional”, a link. Outside a Field, Label is a plain label — give it `htmlFor`.
FieldError{ children?, errors? }Render it always and pass the message or nothing. `errors` takes a validator's list and shows each once.
FieldSet{ legend?, description? }Related fields under one name; `disabled` on it disables everything inside.
useFieldControl(props) => attrsFor your own control: returns the id and aria attributes to spread on it.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/forms/Field.tsx349 lines
"use client";

import {
  createContext,
  useContext,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useState,
  type FieldsetHTMLAttributes,
  type HTMLAttributes,
  type LabelHTMLAttributes,
  type ReactNode,
} from "react";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { EASE_OUT_EXPO } from "../../lib/motion.js";

/* Layout effects warn on the server in older Reacts; the registrations below
   only matter once there is a DOM to describe. */
const useIso = typeof window === "undefined" ? useEffect : useLayoutEffect;

type FieldCtx = {
  controlId: string;
  labelId: string;
  descriptionId: string;
  errorId: string;
  invalid: boolean;
  disabled: boolean;
  required: boolean;
  hasDescription: boolean;
  hasError: boolean;
  setControlId: (id: string | null) => void;
  setDescription: (on: boolean) => void;
  setError: (on: boolean) => void;
};

const Ctx = createContext<FieldCtx | null>(null);

/** The field a control sits in, or null outside one. */
export function useField() {
  return useContext(Ctx);
}

export type FieldControlProps = {
  id?: string;
  disabled?: boolean;
  required?: boolean;
  /** Mark the control wrong without a Field — the hairline goes ink and `aria-invalid` is set. */
  invalid?: boolean;
  "aria-invalid"?: boolean | "true" | "false" | "grammar" | "spelling";
  "aria-describedby"?: string;
  "aria-errormessage"?: string;
};

/**
 * The attributes a control needs from the field around it: the id the label
 * points at, `aria-describedby` for the description (and the error, once there
 * is one), `aria-errormessage`, `aria-invalid`, and the field's disabled and
 * required. A control's own props win over the field's. Exported so any
 * control — a checkbox, a switch — can join a Field the same way.
 */
export function useFieldControl(props: FieldControlProps) {
  const f = useField();
  const own = props.id;
  useIso(() => {
    if (!f || !own) return;
    f.setControlId(own);
    return () => f.setControlId(null);
  }, [f?.setControlId, own]);

  const invalid =
    props.invalid ?? (props["aria-invalid"] !== undefined ? props["aria-invalid"] !== false && props["aria-invalid"] !== "false" : !!f?.invalid);
  const described = [props["aria-describedby"], f?.hasDescription && f.descriptionId, invalid && f?.hasError && f.errorId]
    .filter(Boolean)
    .join(" ");
  return {
    id: own ?? f?.controlId,
    disabled: props.disabled ?? f?.disabled,
    required: props.required ?? f?.required,
    "aria-invalid": invalid || undefined,
    "aria-describedby": described || undefined,
    "aria-errormessage": props["aria-errormessage"] ?? (invalid && f?.hasError ? f.errorId : undefined),
  };
}

export type FieldProps = HTMLAttributes<HTMLDivElement> & {
  /** Wrong. Left unset, the field is invalid whenever its FieldError has something to say. */
  invalid?: boolean;
  disabled?: boolean;
  required?: boolean;
  /** `vertical` stacks label over control. `horizontal` puts the label beside it — for a switch or a checkbox. */
  orientation?: "vertical" | "horizontal";
  /** The control's id. Defaults to a generated one; a control's own `id` also works. */
  controlId?: string;
};

/**
 * One question on a form: a label, the control that answers it, a line of help
 * and, when it is wrong, the reason.
 *
 * Its job is the wiring nobody remembers to do by hand. The label's `for`, the
 * control's id, `aria-describedby` pointing at the help and the error,
 * `aria-errormessage`, `aria-invalid` — all of it is generated here and handed
 * to whatever control sits inside, so a field is accessible by being written
 * plainly rather than by being written carefully. The description and error
 * register themselves when they render, so the control never points at an id
 * that is not on the page.
 *
 * A wrong field says so in three ways at once, none of them red: the hairline
 * goes ink, the error line carries a mark, and the reason is announced. The
 * palette has no warning colour, and a field that is only red is a field a
 * colour-blind reader cannot see is wrong.
 */
export function Field({
  invalid,
  disabled = false,
  required = false,
  orientation = "vertical",
  controlId,
  className = "",
  children,
  ...rest
}: FieldProps) {
  const base = useId();
  const [ownId, setControlId] = useState<string | null>(null);
  const [hasDescription, setDescription] = useState(false);
  const [hasError, setError] = useState(false);
  const isInvalid = invalid ?? hasError;

  const ctx = useMemo<FieldCtx>(
    () => ({
      controlId: controlId ?? ownId ?? `${base}-control`,
      labelId: `${base}-label`,
      descriptionId: `${base}-description`,
      errorId: `${base}-error`,
      invalid: isInvalid,
      disabled,
      required,
      hasDescription,
      hasError,
      setControlId,
      setDescription,
      setError,
    }),
    [base, controlId, ownId, isInvalid, disabled, required, hasDescription, hasError]
  );

  return (
    <Ctx.Provider value={ctx}>
      <div
        data-invalid={isInvalid || undefined}
        data-disabled={disabled || undefined}
        data-orientation={orientation}
        className={`group/field flex min-w-0 ${
          orientation === "horizontal" ? "flex-row items-start justify-between gap-4" : "flex-col gap-1.5"
        } ${className}`}
        {...rest}
      >
        {children}
      </div>
    </Ctx.Provider>
  );
}

export type FieldContentProps = HTMLAttributes<HTMLDivElement>;

/** The label and description as one column, beside a control in a horizontal Field. */
export function FieldContent({ className = "", ...rest }: FieldContentProps) {
  return <div className={`flex min-w-0 flex-1 flex-col gap-1 ${className}`} {...rest} />;
}

export type LabelProps = LabelHTMLAttributes<HTMLLabelElement> & {
  /** Right of the label: "Optional", a "Forgot it?" link. */
  aside?: ReactNode;
};

/**
 * A control's name. Inside a Field it finds its control by itself; on its own
 * it is a plain `<label>` — give it `htmlFor`.
 *
 * It separates from the help under it on weight, not size or colour: medium
 * against regular, both on the reading greys, so the name is found first and
 * the help is still easy to read. There is no asterisk — "Optional" on the few
 * fields that are says the same thing about the form with less ink.
 */
export function Label({ aside, htmlFor, id, className = "", children, ...rest }: LabelProps) {
  const f = useField();
  const label = (
    <label
      htmlFor={htmlFor ?? f?.controlId}
      id={id ?? f?.labelId}
      className={`select-none text-[13px] font-medium leading-[18px] text-gray-1000 group-data-[disabled]/field:opacity-50 ${aside ? "" : className}`}
      {...rest}
    >
      {children}
    </label>
  );
  if (!aside) return label;
  return (
    <div className={`flex items-baseline justify-between gap-3 ${className}`}>
      {label}
      <span className="text-[12px] text-faint">{aside}</span>
    </div>
  );
}

export type FieldDescriptionProps = HTMLAttributes<HTMLParagraphElement>;

/** The help under a control. Joins the control's `aria-describedby` while it is on the page. */
export function FieldDescription({ id, className = "", ...rest }: FieldDescriptionProps) {
  const f = useField();
  const set = f?.setDescription;
  useIso(() => {
    if (!set) return;
    set(true);
    return () => set(false);
  }, [set]);
  return (
    <p
      id={id ?? f?.descriptionId}
      className={`text-[12px] leading-[17px] text-muted group-data-[disabled]/field:opacity-50 ${className}`}
      {...rest}
    />
  );
}

const filled = (n: ReactNode) => n !== undefined && n !== null && n !== false && n !== "";

type ErrorLike = string | { message?: string } | null | undefined | false;

export type FieldErrorProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
  /** The reason. Empty, null or false means there is nothing wrong. */
  children?: ReactNode;
  /** Or a list — from a schema validator, say. Duplicates are shown once. */
  errors?: ErrorLike[];
};

function ErrorMark() {
  return (
    <svg viewBox="0 0 16 16" aria-hidden className="mt-[2.5px] h-3 w-3 shrink-0">
      <circle cx="8" cy="8" r="7" fill="currentColor" />
      <path d="M8 4.6v4.2" stroke="var(--color-background-100)" strokeWidth="1.6" strokeLinecap="round" />
      <circle cx="8" cy="11.3" r="0.95" fill="var(--color-background-100)" />
    </svg>
  );
}

/**
 * Why the field is wrong, under it.
 *
 * Render it always and pass it the message, or nothing: the box stays mounted
 * as a polite live region, so the reason is spoken when it arrives rather than
 * only when someone tabs back. While it has something to say it also marks the
 * field invalid, unless the Field was told otherwise.
 *
 * The line folds open by height on expo-out, not the house curve — it moves the
 * rest of the form down, and anything that shoves layout must settle, not
 * bounce. Under reduced motion it appears in place.
 */
export function FieldError({ children, errors, id, className = "", ...rest }: FieldErrorProps) {
  const f = useField();
  const reduce = useReducedMotion();
  const list = errors
    ? [...new Set(errors.map((e) => (typeof e === "string" ? e : e ? e.message : undefined)).filter((e): e is string => !!e))]
    : [];
  const content: ReactNode = filled(children)
      ? children
      : list.length > 1
        ? (
            <ul className="flex flex-col gap-0.5">
              {list.map((e) => (
                <li key={e}>{e}</li>
              ))}
            </ul>
          )
        : list[0];
  const has = filled(content);
  const set = f?.setError;
  useIso(() => {
    if (!set) return;
    set(has);
    return () => set(false);
  }, [set, has]);

  return (
    <LazyMotion features={domAnimation} strict>
      <div id={id ?? f?.errorId} aria-live="polite" aria-atomic="true" className={className} {...rest}>
        <AnimatePresence initial={false}>
          {has && (
            <m.div
              key="error"
              className="overflow-hidden"
              initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
              animate={reduce ? { opacity: 1 } : { height: "auto", opacity: 1 }}
              exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
              transition={{ duration: reduce ? 0 : 0.2, ease: EASE_OUT_EXPO }}
            >
              <div className="flex gap-1.5 text-[12px] leading-[17px] text-gray-1000">
                <ErrorMark />
                <div className="min-w-0">{content}</div>
              </div>
            </m.div>
          )}
        </AnimatePresence>
      </div>
    </LazyMotion>
  );
}

export type FieldGroupProps = HTMLAttributes<HTMLDivElement>;

/** Fields in a column, at the one gap that keeps each label with its own control. */
export function FieldGroup({ className = "", ...rest }: FieldGroupProps) {
  return <div className={`flex flex-col gap-5 ${className}`} {...rest} />;
}

export type FieldSetProps = Omit<FieldsetHTMLAttributes<HTMLFieldSetElement>, "title"> & {
  /** The question the fields answer together — "Shipping address". */
  legend?: ReactNode;
  /** A line under the legend. Joins the fieldset's `aria-describedby`. */
  description?: ReactNode;
};

/**
 * Related fields under one name. It is a real `<fieldset>` and `<legend>`, so a
 * screen reader says "Shipping address, group" before the first field, and
 * `disabled` on it disables every control inside without each being told.
 *
 * No box around it. A legend and a gap already say "these belong together";
 * a border would say it again and make the form look like a stack of cards.
 */
export function FieldSet({ legend, description, className = "", children, ...rest }: FieldSetProps) {
  const id = useId();
  return (
    <fieldset aria-describedby={description ? `${id}-d` : undefined} className={`m-0 min-w-0 border-0 p-0 ${className}`} {...rest}>
      {/* The legend must be the fieldset's first child to name it, so it sits outside the column. */}
      {legend && (
        <legend className={`p-0 text-[14px] font-medium leading-5 text-gray-1000 ${description ? "mb-1" : "mb-4"}`}>{legend}</legend>
      )}
      {description && (
        <p id={`${id}-d`} className="mb-4 text-[12px] leading-[17px] text-muted">
          {description}
        </p>
      )}
      <div className="flex flex-col gap-5">{children}</div>
    </fieldset>
  );
}