affectUI › Components › Inputs

Form Field

The frame around any control – label with a required mark or Optional, a hint, an error in its place and a count, all wired up with ids and aria.

Free and open source (MIT). Install: npx shadcn@latest add https://www.affectui.com/r/form-field.json · Import from @/components/ui/form-field

When to use

  • Any control that needs a label, a hint and an error but doesn't draw them itself: a plain <input> or <select>, a Number Input without its own label, a slider, a radio group, your own picker
  • Forms that mix controls – every label, hint and error sits at the same distance and looks the same, whatever is inside

When not to use

  • Around a Text Field, Text Area or Select that already has its label, hint and error – pass those props to them instead
  • A single checkbox or switch – their own label and description sit next to them (Checkbox, Switch)
  • Grouping several fields under one heading – use a <fieldset> with a <legend>

Usage

import { useState } from "react";
import { FormField } from "@/components/ui/form-field";

export function AgentForm() {
  const [name, setName] = useState("");
  const [budget, setBudget] = useState("40");
  const over = Number(budget) > 100;

  return (
    <form className="grid gap-4">
      <FormField label="Agent name" required counter={`${name.length}/40`}>
        <input value={name} maxLength={40} onChange={(e) => setName(e.target.value)} />
      </FormField>

      <FormField
        label="Budget per run"
        hint="In US dollars – the run stops once it's spent."
        error={over ? "Your plan allows up to $100 per run." : undefined}
      >
        <input inputMode="decimal" value={budget} onChange={(e) => setBudget(e.target.value)} />
      </FormField>

      {/* Controls that need the ids somewhere else get them as a function */}
      <FormField label="Reasoning" hint="How long the agent may think.">
        {(field) => <EffortPicker {...field} aria-labelledby={field.labelId} />}
      </FormField>
    </form>
  );
}

Props

NameTypeDefaultDescription
labelReactNodeShown above the control; a <label> tied to it, so a click focuses it
hintReactNodeA quiet line under the control – the error takes its place while there is one
errorReactNodeShows the message where the hint was and sets aria-invalid on the control; true marks it invalid without a line
requiredbooleanfalseA quiet * after the label and aria-required on the control; native inputs, selects and textareas also get required
optionalbooleanfalse"Optional" next to the label – for forms where most fields are required
counterReactNodeAt the end of the line under the control, e.g. "12/40" – linked with aria-describedby
disabledbooleanfalseDims the whole field and passes disabled to the control
htmlForstringThe control's id – by default it keeps its own id or gets one made up
childrenReactElement | (field) => ReactNodeOne element, which gets id, aria-describedby, aria-invalid and aria-required – or a function that receives them (plus labelId) to spread where they belong
...div propsComponentProps<"div">className, style, ref and the rest go on the wrapper

Keyboard

  • Tab: Goes straight to the control – the label, hint and count aren't stops

Accessibility

  • The label is a real <label for>, so clicking it focuses the control and screen readers read it as the control's name
  • The hint, the error and the count are linked with aria-describedby (added to any the control already has); an error sets aria-invalid and is announced politely when it appears
  • A <div role="radiogroup"> or similar, which a <label> can't name, gets aria-labelledby pointing at the label; in the function form, use labelId
  • The * is hidden from screen readers – aria-required says it instead
  • dir="auto" on the message, so Arabic or Hebrew text reads the right way

Responsive

  • Fills its container; long labels and messages wrap, even unbroken words, and never push it wider
  • The count keeps its place at the end of the line while the hint wraps beside it
  • Dark mode built in: switches when an ancestor (usually <html>) has the class dark or data-theme="dark"

Requirements

  • React 19

Related

  • Text Field – One input for every form – label, hint and error in place, prefix and suffix, clear, show-password and copy buttons, a character count
  • Number Input – A field for one number – − and + that repeat while held, arrow keys and Page Up / Down, min, max and step, a unit like tokens or %, formatted for the locale
  • Select – Pick one option in the Text Field's look – icons and a second line per option, a gliding highlight, type-ahead and a list that never gets cut off
  • Text Area – The multi-line twin of the Text Field – grows with the text, a character count, a button for more room and ⌘ Enter to send