Field
A label, a control, its help and its error — wired to each other without writing an id.
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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| Field invalidboolean | boolean | Left unset, the field is wrong whenever its FieldError has something to say. |
| Field disabled / requiredboolean | boolean | Handed to the control inside. |
| Field orientation"vertical" | "horizontal" | "vertical" | "horizontal"= "vertical" | Horizontal puts the label beside the control — for a switch. Wrap label and help in FieldContent. |
| Label asideReactNode | ReactNode | Right of the label: “Optional”, a link. Outside a Field, Label is a plain label — give it `htmlFor`. |
| FieldError{ children?, errors? } | { children?, errors? } | Render it always and pass the message or nothing. `errors` takes a validator's list and shows each once. |
| FieldSet{ legend?, description? } | { legend?, description? } | Related fields under one name; `disabled` on it disables everything inside. |
| useFieldControl(props) => attrs | (props) => attrs | For 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.
"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>
);
}