← All components

Input

A line of text on a hairline, with a halo for focus and ink, not red, for wrong.

That address is missing its domain — try name@studio.com.
Set by your workspace

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

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

Input — usage11 lines
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

PropNotes
size"sm" | "md" | "lg"28, 36 or 40 pixels — the same heights as Button, so a field and its button line up.
invalidbooleanWrong, outside a Field. Inside one, the field decides from its FieldError.
…input propsInputHTMLAttributesEverything 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.

packages/ui/src/components/forms/Input.tsx61 lines
"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}`}
    />
  );
});
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>
  );
}