Forms

Label, description and error come from the field — not from markup beside it

Use the field's own slots: the label/description props on a conform-* variant, Label and Description inside a react-aria field, and for errors either FieldError (inside a field context) or an element carrying id={field.errorId}. A red paragraph next to a control is not attached to it.

Tier 2errorlintrender-field-text-through-the-field

Validation is yours. Wiring is the library's.

What this catches

An agent renders the error message as a red paragraph next to the control, because that is what it looks like on screen. The control is left pointing at an id that does not exist, so the one group of users who cannot see the red text is the group that hears nothing.

Why

An error message is only an error message if the control points at it. getInputProps sets aria-describedby to field.errorId the moment a field is invalid, so if nothing on the page carries that id the attribute dangles: the message is on screen, the screen reader says nothing, and the markup reviews as correct. This is the single most common way an accessible component library ends up in an inaccessible form.

Inside a react-aria field — TextField, NumberField, CheckboxGroup, DatePicker — FieldError does the whole job: it renders with slot="errorMessage", the field owns the id, and it inherits the invalid state so it appears and disappears on its own. Outside one, FieldError renders null on purpose (it needs a FieldErrorContext), which is why a bare Checkbox needs its message to carry id={field.errorId} explicitly. Knowing which case you are in is the whole skill here.

The same logic covers the label. A placeholder is not a label: it disappears the moment someone types, it is not reliably announced as the accessible name, and at the contrast most themes give it, it is hard to read before it vanishes. Pass the label prop and let the component wire htmlFor and id, which it can do correctly and you can only do repetitively.

Use this instead

A hand-written error message
  • FieldError from @/components/fieldinside a react-aria field (TextField, NumberField, CheckboxGroup, DatePicker …)
  • id={field.errorId} from @conform-to/reactoutside one — the message must carry the id the control already points at
A hand-written label
  • label prop from @/components/conform-fieldon any conform-* variant
  • Label from @/components/fieldcomposing a field yourself
A hand-written hint

Wrong / right

An error the control cannot point at

Real code from src/components/conform-checkbox.tsx (fixed — the message had no id while the control already referenced one)

Don't

const inputProps = getInputProps(field, { type: "checkbox" })
// inputProps carries aria-describedby={field.errorId} whenever the field is invalid

<Checkbox {...inputProps} isInvalid={hasErrors}>{label}</Checkbox>
{hasErrors && <p className="text-sm text-red-500">{field.errors?.join(", ")}</p>}

Do

<Checkbox {...inputProps} isInvalid={hasErrors}>{label}</Checkbox>
{hasErrors && (
  <p id={field.errorId} className="block text-[12px] text-red-500">
    {field.errors?.join(", ")}
  </p>
)}

One attribute. Without it the checkbox announces "invalid" and nothing else — the reason is on screen for everyone except the people who most need it read out. Note that FieldError is not the fix here: a bare Checkbox provides no FieldErrorContext, so FieldError would render null.

Inside a react-aria field, FieldError is the fix

Don't

<TextField isInvalid={errors.length > 0}>
  <Label>Email</Label>
  <Input />
  {errors.length > 0 && <p className="mt-1 text-[12px] text-red-500">{errors[0]}</p>}
</TextField>

Do

<TextField isInvalid={errors.length > 0}>
  <Label>Email</Label>
  <Input />
  <FieldError>{errors.join(", ")}</FieldError>
</TextField>

Styled identically, connected differently. FieldError renders into the errorMessage slot the field's aria-describedby already points at, and it hides itself when the field goes valid — two things the paragraph has to be told to do.

A placeholder standing in for a label

Don't

<ConformField field={fields.email} placeholder="Email address" />

Do

<ConformField field={fields.email} label="Email address" placeholder="you@example.com" />

The placeholder is the hint, not the name. Once someone starts typing, the labelless version has nothing on screen saying what the value is — which is also what a screen reader had all along.

How to check this

Add one of these to your project and the rule holds without anyone having to remember it — including the agent writing half the JSX. The exceptions below are already applied, so a documented carve-out will not be reported.

What it will and will not catch: The check finds error text in an element with no id; it cannot tell whether you are inside a react-aria field, which is what decides between FieldError and an explicit id. It does not look at labels at all — a placeholder standing in for a label stays a review question, worth looking for whenever you touch a form.

Biome — GritQL plugin

biome

Biome has no built-in rule for this one, so it ships as a GritQL plugin. Save it as ui-lib-rules/render-field-text-through-the-field.grit and add that path to `plugins` in your biome.jsonc. Its documented exceptions are compiled in as `$filename` guards, because Biome's overrides do not scope plugins.

// Label, description and error come from the field — not from markup beside it
// AUTO-GENERATED from https://ui-lib.quebi.de/api/rules/render-field-text-through-the-field.json — do not edit by hand.
//
// https://ui-lib.quebi.de/rules/render-field-text-through-the-field

language js;

JsxElement(opening_element = $open, children = $children) as $message where {
  $open <: JsxOpeningElement(name = $el, attributes = $attrs),
  $el <: r"^(?:p|span|div)$",
  $children <: contains `$field.errors` until JsxElement(),
  $children <: not contains `$field.errors.length`,
  $attrs <: not contains JsxAttribute(name = r"^id$"),
  // documented exception: components/ui/**
  not $filename <: r".*components/ui/.*",
  // documented exception: src/components/**
  not $filename <: r".*src/components/.*",
  register_diagnostic(
    span = $message,
    message = "This renders field errors in an element the control cannot reference. Inside a react-aria field use <FieldError> from @/components/field; outside one, put id={field.errorId} on this element so the aria-describedby that getInputProps already emits resolves to it. See https://ui-lib.quebi.de/rules/render-field-text-through-the-field",
    severity = "error"
  )
}

ripgrep — no setup at all

ripgrep

Finds candidates for review in any repo, linter or not. Coarser than the Biome check: it reads lines, not syntax, so expect false positives and treat a clean run as weaker evidence than a clean lint run.

# render-field-text-through-the-field — candidates for review
rg -n -g '*.{tsx,jsx}' \
  -g '!components/ui/**' \
  -g '!src/components/**' \
  "<(p|span|div)[^>]*>\\s*\\{[a-zA-Z]+\\.errors"

Claiming an exception that is not a path

biome

One exception on this rule is a judgement call, so it cannot be a path. Biome's suppression syntax has a slot for the reason — fill it, because that note is what makes the carve-out reviewable instead of invisible.

{/* biome-ignore plugin: A form-level error summary that belongs to no single field — "Your session expired, please sign in again" is not a field error. */}

Enforcing more than this one rule? Take the whole config instead of collecting snippets.

Exceptions

Carve-outs are part of the rule, not a way around it. Each one is already an ignore glob in the checks above, so the cases listed here need no disable comment — and a case that is not listed is one to argue for, not to silence.

Your copy of the ui-lib component source (components/ui/**)
The conform-* variants and the field primitives are where this markup is supposed to live — they are the layer that renders the label, the description, and the error, and wires the ids between them.
A form-level error summary that belongs to no single field
"Your session expired, please sign in again" is not a field error. Render it once at the top of the form in a live region, pointed at by the form's own aria-describedby (getFormProps supplies it), rather than trying to attach it to a control.

Scope and enforcement

Applies to
  • app/**/*.{tsx,jsx}
  • src/**/*.{tsx,jsx}
Enforced by
lint