Input
A line of text on a hairline, with a halo for focus and ink, not red, for wrong.
The idea
A hairline on the page rather than a grey well, because a filled field reads as disabled and a field you can type into should look like the page is waiting for you. Focus steps the hairline up and lays a soft halo round it — a widening, not a colour — so the field you are in is found at a glance without spending the accent. Wrong turns the hairline ink: the reason and its mark live in the error line under it, and the border only points at which field to read.
Composes Field.
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 { Field, Label, Input, FieldError } from "mihirmodi-ui";
<Field>
<Label>Work email</Label>
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
{/* Wrong while this has something to say. */}
<FieldError>{error}</FieldError>
</Field>
{/* Or on its own */}
<Input size="sm" aria-label="Filter" placeholder="Filter" />Props
| Prop | Type | Notes |
|---|---|---|
| size"sm" | "md" | "lg" | "sm" | "md" | "lg"= "md" | 28, 36 or 40 pixels — the same heights as Button, so a field and its button line up. |
| invalidboolean | boolean | Wrong, outside a Field. Inside one, the field decides from its FieldError. |
| …input propsInputHTMLAttributes | InputHTMLAttributes | Everything a native input takes. Inside a Field, `id`, `aria-describedby` and `disabled` come from the field. |
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 { forwardRef, type InputHTMLAttributes } from "react";
import { useFieldControl } from "./Field.js";
export type ControlSize = "sm" | "md" | "lg";
/**
* Height, corner, inset and type for each size — shared by every text control
* here, so an Input, a NativeSelect and a Combobox in one row line up. The
* corner steps down with the height, so a small field is not a pill.
*/
export const controlSizes: Record<ControlSize, string> = {
sm: "h-7 rounded-[8px] px-2.5 text-[13px]",
md: "h-9 rounded-[10px] px-3 text-[14px]",
lg: "h-10 rounded-[10px] px-3.5 text-[14px]",
};
/**
* The material every bordered text control wears: a hairline that steps up a
* grey on hover and focus, a soft three-pixel halo while focused (the same one
* Select wears while open), ink when invalid, and a dim when disabled.
*/
export const controlSurface =
"w-full min-w-0 border-[0.5px] border-gray-alpha-400 bg-background-100 text-gray-1000 transition-[border-color,box-shadow] duration-150 ease-geist placeholder:text-faint hover:border-gray-alpha-600 focus:border-gray-alpha-600 focus:shadow-[0_0_0_3px_var(--color-gray-alpha-200)] aria-invalid:border-gray-1000 disabled:cursor-not-allowed disabled:border-gray-alpha-400 disabled:opacity-50 motion-reduce:transition-none";
export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size"> & {
size?: ControlSize;
/** Wrong, without a Field around it. Inside a Field, the field decides. */
invalid?: boolean;
};
/**
* A single line of text.
*
* It is a hairline on the page, not a well: a grey-filled field reads as
* disabled, and a field you can type into should look like the page is
* waiting for you. Focus steps the hairline up and lays a soft halo around it
* — a widening, not a colour — so the field you are in is found at a glance
* without the accent, which belongs to the focus ring the host draws.
*
* Invalid turns the hairline to ink rather than red. The reason, and the mark
* that says there is one, live in the FieldError under it; the border only
* points at which field to read. Inside a Field it takes its id, description,
* error and disabled state from the field, so none of that is written twice.
*/
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ size = "md", invalid, className = "", type = "text", ...rest },
ref
) {
const field = useFieldControl({ ...rest, invalid });
return (
<input
ref={ref}
type={type}
{...rest}
{...field}
className={`${controlSurface} ${controlSizes[size]} file:mr-3 file:h-full file:border-0 file:bg-transparent file:text-[13px] file:font-medium file:text-gray-1000 ${className}`}
/>
);
});
"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>
);
}