Skip to content
design
Components

Form field

A label, an input, a hint and the input's error, stacked.

On this page
m

45 m of chain out in 9 m of water gives about 50 m.

m

The depth sounder has sent no reading since 01:58 UTC, so this fills in when it does.

The anchor watch form after a press of Arm with a swing radius out of range: required fields, hints, units and errors.
Code
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { Select } from "@bosunkit/components/select";
import { Anchor, MapPin } from "lucide-react";
import {
  useId,
  useState,
  type ChangeEvent,
  type CSSProperties,
  type Dispatch,
  type ReactElement,
  type SetStateAction,
  type SubmitEvent,
} from "react";

export const description: string =
  "Arming the anchor watch: required, numeric, read-only and disabled fields with hints, units and an icon, a select " +
  "named by its field's label, and the errors a press of Arm leaves under the fields that need a fix.";

const MINIMUM_RADIUS_METERS: number = 20;
const MAXIMUM_RADIUS_METERS: number = 200;

const ROW: CSSProperties = { display: "flex", alignItems: "center", gap: "var(--space-sm)" };

interface Errors {
  readonly radius?: string | undefined;
  readonly phone?: string | undefined;
}

function validate(radius: string, phone: string): Errors {
  const meters: number = Number(radius);
  const digits: number = phone.replace(/\D/g, "").length;
  return {
    radius:
      radius.trim() === "" || Number.isNaN(meters) || meters < MINIMUM_RADIUS_METERS || meters > MAXIMUM_RADIUS_METERS
        ? `The anchor watch takes a swing radius from ${String(MINIMUM_RADIUS_METERS)} to ` +
          `${String(MAXIMUM_RADIUS_METERS)} m; ${radius.trim() === "" ? "nothing" : `${radius} m`} was entered. ` +
          "Enter a radius in that range."
        : undefined,
    phone:
      digits < 8
        ? "The alarm phone number has fewer than 8 digits. Enter it with the country code, such as +33."
        : undefined,
  };
}

export default function Showcase(): ReactElement {
  const formId: string = useId();
  const contactId: string = useId();
  const [radius, setRadius]: [string, Dispatch<SetStateAction<string>>] = useState<string>("350");
  const [phone, setPhone]: [string, Dispatch<SetStateAction<string>>] = useState<string>("+33 6 12 34 56 78");
  const [contact, setContact]: [string, Dispatch<SetStateAction<string>>] = useState<string>("marta");
  // The example opens as the crew see the form after one press of Arm with a radius out of range.
  const [errors, setErrors]: [Errors, Dispatch<SetStateAction<Errors>>] = useState<Errors>((): Errors =>
    validate("350", "+33 6 12 34 56 78"),
  );
  const [armed, setArmed]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);

  function arm(event: SubmitEvent<HTMLFormElement>): void {
    event.preventDefault();
    const found: Errors = validate(radius, phone);
    setErrors(found);
    setArmed(found.radius === undefined && found.phone === undefined);
  }

  return (
    <form
      id={formId}
      onSubmit={arm}
      noValidate
      style={{
        width: "100%",
        maxWidth: 560,
        display: "grid",
        gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
        gap: "var(--space-md)",
      }}
    >
      <FormField label="Anchorage" required>
        <Input defaultValue="Baie de Morgat, off the lifeboat slip" />
      </FormField>

      <FormField label="Drop position">
        <Input readOnly value="48°13.42′N 004°29.87′W" icon={<MapPin />} />
      </FormField>

      <FormField
        label="Swing radius"
        required
        hint="45 m of chain out in 9 m of water gives about 50 m."
        error={errors.radius}
      >
        <Input
          type="number"
          inputMode="decimal"
          min={MINIMUM_RADIUS_METERS}
          max={MAXIMUM_RADIUS_METERS}
          unit="m"
          value={radius}
          onChange={(event: ChangeEvent<HTMLInputElement>): void => {
            setRadius(event.target.value);
            setArmed(false);
          }}
        />
      </FormField>

      <FormField
        label="Depth at drop"
        hint="The depth sounder has sent no reading since 01:58 UTC, so this fills in when it does."
      >
        <Input disabled placeholder="No reading" value="" unit="m" />
      </FormField>

      <FormField label="Who hears the alarm" htmlFor={contactId}>
        <Select.Root value={contact} onValueChange={(next: string | null): void => { (setContact)(next ?? ""); }}>
          <Select.Trigger id={contactId}>
            <Select.Value />
          </Select.Trigger>
          <Select.Portal><Select.Positioner alignItemWithTrigger={true} ><Select.Popup ><Select.List>
            <Select.Item value="marta">Marta Lindqvist, on watch</Select.Item>
            <Select.Item value="anneke">Anneke de Vries, skipper</Select.Item>
            <Select.Item value="all">Everyone aboard</Select.Item>
          </Select.List></Select.Popup></Select.Positioner></Select.Portal>
        </Select.Root>
      </FormField>

      <FormField label="Alarm phone" required error={errors.phone}>
        <Input
          type="tel"
          autoComplete="tel"
          value={phone}
          onChange={(event: ChangeEvent<HTMLInputElement>): void => {
            setPhone(event.target.value);
            setArmed(false);
          }}
        />
      </FormField>

      <div style={{ ...ROW, gridColumn: "1 / -1" }}>
        <Button variant="primary" type="submit" form={formId}>
          <Anchor size={14} aria-hidden="true" />
          Arm anchor watch
        </Button>
        {armed && <Badge variant="green">Armed at {radius} m</Badge>}
      </div>
    </form>
  );
}

A form field names an input and carries its hint and its error. The label sits above the input as a <label>, the hint under it, and the error under the hint, in the alarm color. The error is a role="alert", so a screen reader announces it when it appears. Say what is wrong and how to fix it, in one sentence.

A kit input inside the field takes everything from it: the label's id, so the label names it and a press on the label focuses it; the hint and the error as its description; aria-required when the field is required; and aria-invalid while the field shows an error. Another control takes the field's label through htmlFor, set to the control's own id.

Examples

Field

A label above an input.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";

export const description: string = "A label above an input.";

export default function Field(): ReactElement {
  return (
    <FormField label="Vessel name">
      <Input defaultValue="Althea" />
    </FormField>
  );
}

With error

An error under the input says what is wrong and how to fix it; it is announced when it appears.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";

export const description: string =
  "An error under the input says what is wrong and how to fix it; it is announced when it appears.";

export default function WithError(): ReactElement {
  return (
    <FormField label="Vessel name" error="Enter the vessel's name.">
      <Input defaultValue="" />
    </FormField>
  );
}

Required

required puts "Required" after the label, in the crew's language, and marks a kit input aria-required, so assistive technology announces it. The word is hidden from assistive technology, so the input's name stays the field's name.

A required field says so after its label, and the input is marked aria-required so assistive technology says it.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";

export const description: string =
  "A required field says so after its label, and the input is marked aria-required so assistive technology says it.";

export default function Required(): ReactElement {
  return (
    <FormField label="Vessel name" required>
      <Input placeholder="As on the registration papers" />
    </FormField>
  );
}

With hint

hint sits under the input and is tied to a kit input through aria-describedby, so a screen reader reads it with the field. Keep the hint to one line.

Nine digits, from the vessel's radio license.

A hint under the input, which the input reads with its name through aria-describedby.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";

export const description: string =
  "A hint under the input, which the input reads with its name through aria-describedby.";

export default function WithHint(): ReactElement {
  return (
    <FormField label="MMSI" hint="Nine digits, from the vessel's radio license.">
      <Input inputMode="numeric" maxLength={9} defaultValue="244630123" />
    </FormField>
  );
}

Number with a unit

Give the input its unit: it sits inside the input's right edge and is read after the value, so the field's name need not repeat it. A value shown to the crew keeps its unit in every place it appears.

m
A number with its unit inside the input's right edge, so the crew never have to guess whether it is meters or feet.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";

export const description: string =
  "A number with its unit inside the input's right edge, so the crew never have to guess whether it is meters or feet.";

export default function NumberWithUnit(): ReactElement {
  return (
    <FormField label="Safety depth">
      <Input type="number" inputMode="decimal" min={0} step={0.1} defaultValue="2.5" unit="m" />
    </FormField>
  );
}

Read-only

Use readOnly for a value the crew may need to copy. It stays focusable and is read out with its value.

A read-only field shows a value the crew can select and copy but not change; it stays in the tab order.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";

export const description: string =
  "A read-only field shows a value the crew can select and copy but not change; it stays in the tab order.";

export default function ReadOnly(): ReactElement {
  return (
    <FormField label="Vessel ID">
      <Input readOnly defaultValue="althea-hanse-460" />
    </FormField>
  );
}

Disabled

Use disabled for a value that cannot be changed right now, and say why in the hint. A disabled input is skipped by the keyboard, so the hint is the only place a screen reader finds the reason when it reads the form.

hr

Read from the engine bus. It cannot be edited while the bus is connected.

A disabled field is dimmed and skipped by the keyboard; the hint says why it cannot be changed.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";

export const description: string =
  "A disabled field is dimmed and skipped by the keyboard; the hint says why it cannot be changed.";

export default function Disabled(): ReactElement {
  return (
    <FormField label="Engine hours" hint="Read from the engine bus. It cannot be edited while the bus is connected.">
      <Input disabled defaultValue="1,284" unit="hr" />
    </FormField>
  );
}

Password

A password goes in a password input inside the field. Its purpose writes the autocomplete that matches the task, current-password when the crew enter a password they already have and new-password when they choose one, which a password manager uses to fill or generate the value. Its show button helps on a small touch screen, where a mistyped password is hard to spot; the button is named for what pressing it does next and carries no aria-pressed.

A password inside a form field: the field's label names it, and its show button and autocomplete come with it.
Code
import { FormField } from "@bosunkit/components/form-field";
import { PasswordInput } from "@bosunkit/components/password-input";
import type { ReactElement } from "react";

export const description: string =
  "A password inside a form field: the field's label names it, and its show button and autocomplete come with it.";

export default function Password(): ReactElement {
  return (
    <FormField label="Wi-Fi password">
      <PasswordInput purpose="current" defaultValue="windward-12" />
    </FormField>
  );
}

New password

Ask for a new password once, in a password input with purpose="new": its show button lets the crew read what they typed, so a second field adds typing and no check. State the length rule in the error with the count the crew entered.

  • At least 8 charactersnot yet
  • Not a common or leaked passwordchecking
One new-password field with a show button and no second field; it says how many characters are missing.
Code
import { FormField } from "@bosunkit/components/form-field";
import { PasswordInput } from "@bosunkit/components/password-input";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "One new-password field with a show button and no second field; it says how many characters are missing.";

const MINIMUM_LENGTH: number = 8;

export default function NewPassword(): ReactElement {
  const [password, setPassword]: [string, Dispatch<SetStateAction<string>>] = useState<string>("windwar");
  const tooShort: boolean = password !== "" && password.length < MINIMUM_LENGTH;
  return (
    <FormField
      label="New password"
      error={
        tooShort
          ? `The password has ${String(password.length)} characters. Use at least ${String(MINIMUM_LENGTH)}.`
          : undefined
      }
    >
      <PasswordInput purpose="new" minLength={MINIMUM_LENGTH} value={password} onValueChange={setPassword} />
    </FormField>
  );
}

Saves immediately

Save a field as it changes, with no button, when the setting takes effect at once and can be changed back. Save on blur or after a short pause in typing, show the save chip beside the field, and put the reason for a failed save in the field's error. The value stays in the field after a failure so the crew can correct it. The example rejects depths under 1 m to show that path; type 0.5 and wait. For a whole settings row with the same behavior, see setting control.

m
A field with no save button: it saves 600 ms after the last keystroke or on blur, shows the save chip, and puts a failed save's reason in the field.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { SaveChip, type SaveState } from "@bosunkit/components/setting-control";
import {
  useEffect,
  useRef,
  useState,
  type ChangeEvent,
  type Dispatch,
  type ReactElement,
  type RefObject,
  type SetStateAction,
} from "react";

export const description: string =
  "A field with no save button: it saves 600 ms after the last keystroke or on blur, shows the save chip, and puts a failed save's reason in the field.";

const DEBOUNCE_MILLISECONDS: number = 600;
const SAVE_MILLISECONDS: number = 400;
const MINIMUM_METERS: number = 1;

// The example's stand-in for the write: it takes a moment and rejects depths under the minimum.
function save(value: string): Promise<void> {
  return new Promise<void>((resolve: () => void, reject: (reason: Error) => void): void => {
    window.setTimeout((): void => {
      if (Number(value) < MINIMUM_METERS) {
        reject(
          new Error(
            `Couldn't save the safety depth. The minimum is ${String(MINIMUM_METERS)} m; enter ${String(MINIMUM_METERS)} or more.`,
          ),
        );
        return;
      }
      resolve();
    }, SAVE_MILLISECONDS);
  });
}

export default function SavesImmediately(): ReactElement {
  const [value, setValue]: [string, Dispatch<SetStateAction<string>>] = useState<string>("2.5");
  const [state, setState]: [SaveState, Dispatch<SetStateAction<SaveState>>] = useState<SaveState>("idle");
  const [error, setError]: [string | undefined, Dispatch<SetStateAction<string | undefined>>] = useState<
    string | undefined
  >(undefined);
  const timer: RefObject<number | undefined> = useRef<number | undefined>(undefined);
  const attempt: RefObject<number> = useRef<number>(0);
  const lastSaved: RefObject<string> = useRef<string>("2.5");

  // A pending debounce must not fire into an unmounted example.
  useEffect((): (() => void) => {
    return (): void => {
      window.clearTimeout(timer.current);
      attempt.current += 1;
    };
  }, []);

  function commit(next: string): void {
    window.clearTimeout(timer.current);
    if (next === lastSaved.current) {
      return;
    }
    attempt.current += 1;
    const thisAttempt: number = attempt.current;
    setState("saving");
    save(next).then(
      (): void => {
        if (thisAttempt !== attempt.current) {
          return;
        }
        lastSaved.current = next;
        setError(undefined);
        setState("saved");
        timer.current = window.setTimeout((): void => {
          setState("idle");
        }, 2000);
      },
      (reason: unknown): void => {
        if (thisAttempt !== attempt.current) {
          return;
        }
        setError(reason instanceof Error ? reason.message : "Couldn't save the safety depth. Try again.");
        setState("error");
      },
    );
  }

  return (
    <FormField label="Safety depth" error={error}>
      <div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
        <Input
          type="number"
          inputMode="decimal"
          step={0.1}
          unit="m"
          value={value}
          aria-invalid={state === "error"}
          onChange={(event: ChangeEvent<HTMLInputElement>): void => {
            const next: string = event.target.value;
            setValue(next);
            window.clearTimeout(timer.current);
            timer.current = window.setTimeout((): void => {
              commit(next);
            }, DEBOUNCE_MILLISECONDS);
          }}
          onBlur={(): void => {
            commit(value);
          }}
        />
        <SaveChip state={state} />
      </div>
    </FormField>
  );
}

Group of fields

Use a button for anything that changes several values together or cannot be undone. Stack the fields with the medium gap and put the button last. The error appears under the field that needs the fix, after the crew press the button, not while they type.

Three fields stacked with the kit's medium gap, saved together by one button; the error appears under the field that needs the fix.
Code
import { Button } from "@bosunkit/components/button";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import {
  useState,
  type ChangeEvent,
  type Dispatch,
  type SubmitEvent,
  type ReactElement,
  type SetStateAction,
} from "react";

export const description: string =
  "Three fields stacked with the kit's medium gap, saved together by one button; the error appears under the field that needs the fix.";

const MMSI_DIGITS: number = 9;

export default function FormGroup(): ReactElement {
  const [name, setName]: [string, Dispatch<SetStateAction<string>>] = useState<string>("Althea");
  const [callSign, setCallSign]: [string, Dispatch<SetStateAction<string>>] = useState<string>("PD1234");
  const [mmsi, setMmsi]: [string, Dispatch<SetStateAction<string>>] = useState<string>("2446301");
  const [submitted, setSubmitted]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  const mmsiError: string | undefined =
    submitted && mmsi.length !== MMSI_DIGITS
      ? `The MMSI has ${String(mmsi.length)} digits and needs ${String(MMSI_DIGITS)}. Check the number on the radio license.`
      : undefined;
  return (
    <form
      noValidate
      style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}
      onSubmit={(event: SubmitEvent<HTMLFormElement>): void => {
        event.preventDefault();
        setSubmitted(true);
      }}
    >
      <FormField label="Vessel name">
        <Input
          autoComplete="off"
          value={name}
          onChange={(event: ChangeEvent<HTMLInputElement>): void => {
            setName(event.target.value);
          }}
        />
      </FormField>
      <FormField label="Call sign">
        <Input
          autoComplete="off"
          autoCapitalize="characters"
          value={callSign}
          onChange={(event: ChangeEvent<HTMLInputElement>): void => {
            setCallSign(event.target.value);
          }}
        />
      </FormField>
      <FormField label="MMSI" error={mmsiError}>
        <Input
          inputMode="numeric"
          autoComplete="off"
          value={mmsi}
          onChange={(event: ChangeEvent<HTMLInputElement>): void => {
            setMmsi(event.target.value);
          }}
        />
      </FormField>
      <div>
        <Button type="submit" variant="primary">
          Save vessel details
        </Button>
      </div>
    </form>
  );
}

Motion

By default the error line rises in under the input when an error appears, fades its new words in when the error changes, and fades out when the value is fixed; an error there on the first render does not move. motion takes the kit's motion prop, and motion={false} shows and hides the error at once. The error is announced and readable from its first frame, and under reduced motion it only fades.

Press Check with the drafts empty: by default the error rises in under the first field, announced at once, and fades out once a draft is entered and checked; the second field, with motion={false}, shows and hides it at once.
Code
import { Button } from "@bosunkit/components/button";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { useState, type ChangeEvent, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Press Check with the drafts empty: by default the error rises in under the first field, announced at once, and " +
  "fades out once a draft is entered and checked; the second field, with motion={false}, shows and hides it at once.";

function Draft({ label, motion }: { label: string; motion: boolean }): ReactElement {
  const [draft, setDraft]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
  const [error, setError]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
  return (
    <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: "var(--space-sm)" }}>
      <FormField label={label} error={error} {...(motion ? {} : { motion: false })}>
        <Input
          aria-label={label}
          inputMode="decimal"
          value={draft}
          onChange={(event: ChangeEvent<HTMLInputElement>): void => {
            setDraft(event.target.value);
          }}
        />
      </FormField>
      <Button
        size="sm"
        onClick={(): void => {
          setError(draft.trim() === "" ? "Enter the draft in meters, such as 2.1." : "");
        }}
      >
        Check
      </Button>
    </div>
  );
}

export default function Motion(): ReactElement {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-lg)" }}>
      <Draft label="Draft" motion />
      <Draft label="Draft, without motion" motion={false} />
    </div>
  );
}

Props

PropTypeDefaultDescription
labelrequiredstring–The field's name, shown above it in a <label> that names the input.
htmlForstring | undefined–The id of the control the label names. A kit Input inside the field takes a generated id, so it is needed only for another control, or an Input that brings its own id.
hintReactNode | undefined–A line under the input that says what to enter or where the value comes from. A kit Input reads it with its name, through aria-describedby.
requiredboolean | undefinedfalseThe field must be filled: the label says "Required" in the crew's language, and a kit Input is marked aria-required. The input keeps its name; the word is for sighted crew.
errorstring | undefined–What is wrong with the value and how to fix it; announced when it appears. A kit Input is then marked aria-invalid and reads the error with its name.
appearance"plain" | "outlined" | undefined"plain"Label placement; outlined draws the same field semantics on an outline.
childrenrequiredReactNode–The input.
classNamestring | undefined–Classes added to the field's own.
testIdstring | undefined–Becomes data-testid on the field.
motionMotionProp | undefined–How the error line moves. By default an error that appears rises in, new words fade in when it changes, and it fades out when the value is fixed; an error there on the first render does not move. A preset (fade, rise, scale) or { enter, exit, change } chooses; false turns it off. The error is announced and readable from its first frame.
Do
m
Say what is wrong and how to fix it, in one sentence.
Don't
m
Don't write only that the value is wrong. The crew cannot tell what to change.
  • Input: the text input a field usually wraps, with its unit and icon.
  • Setting control: the settings row that saves a field as it changes.
  • Switch: the control for a setting that is on or off.
  • Accessibility: accessible names and announced errors.