Skip to content
Andrew SmithSolutions
← Component library

Field

Forms

Label, control, hint and error as one unit, wired together so the error is announced rather than just coloured.

FormValidationaria-describedbyCSS only — no dependency

Preview

I reply to every message myself.

Controls

Notes

The error is bound with aria-describedby and aria-invalid, not signalled by turning the border red. Colour alone fails SC 1.4.1, and a screen reader gets nothing from it.

The label is a real <label> with htmlFor. Placeholder-as-label disappears exactly when the user needs it — the moment they start typing.

Every text input carries a maxLength. Uncapped free text is the input a form handler regrets.

Source

Show
"use client";

import { useId } from "react";
import { cn } from "@/lib/cn";

const control =
  "w-full rounded-md border bg-paper-raised px-4 py-3 text-[15px] text-ink placeholder:text-ink-faint transition-colors";

export function Field({
  label,
  type = "text",
  hint,
  error,
  required = false,
  maxLength = 200,
  ...rest
}: {
  label: string;
  type?: "text" | "email" | "textarea";
  hint?: string;
  /** Present means invalid. The string is shown AND announced. */
  error?: string;
  required?: boolean;
  maxLength?: number;
} & Omit<React.ComponentProps<"input">, "type" | "required" | "maxLength">) {
  const id = useId();
  const hintId = `${id}-hint`;
  const errorId = `${id}-error`;

  // Colour alone would fail SC 1.4.1 and tells a screen reader nothing. The
  // state travels on aria-invalid and the message via aria-describedby.
  const describedBy = [hint ? hintId : null, error ? errorId : null]
    .filter(Boolean)
    .join(" ");

  const shared = {
    id,
    name: rest.name ?? label.toLowerCase().replace(/\s+/g, "-"),
    required,
    maxLength,
    "aria-invalid": error ? true : undefined,
    "aria-describedby": describedBy || undefined,
    className: cn(control, error ? "border-accent" : "border-control-border focus:border-accent"),
  };

  return (
    <div className="w-full">
      <label htmlFor={id} className="eyebrow block">
        {label}
        {!required && <span className="ml-2 normal-case tracking-normal">(optional)</span>}
      </label>

      <div className="mt-3">
        {type === "textarea" ? (
          <textarea {...shared} rows={4} className={cn(shared.className, "resize-y")} />
        ) : (
          <input {...rest} {...shared} type={type} />
        )}
      </div>

      {hint && !error && (
        <p id={hintId} className="mt-2 text-[13px] text-ink-muted">
          {hint}
        </p>
      )}
      {error && (
        <p id={errorId} role="alert" className="mt-2 text-[13px] text-accent">
          {error}
        </p>
      )}
    </div>
  );
}

export default function Demo({
  type = "email",
  label = "Email",
  hint = "I reply to every message myself.",
  invalid = false,
  required = true,
}: {
  type?: "text" | "email" | "textarea";
  label?: string;
  hint?: string;
  invalid?: boolean;
  required?: boolean;
}) {
  return (
    <div className="w-full max-w-sm">
      <Field
        label={label}
        type={type}
        hint={hint}
        required={required}
        error={invalid ? "That does not look like an email address." : undefined}
        placeholder={type === "email" ? "you@company.com" : undefined}
      />
    </div>
  );
}

Next

Badge

Next step

Tell me where the business is losing time.

No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.