rule 09 — tier 2

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.

errorlintrender-field-text-through-the-field

forms

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. react-aria's own FieldError renders null outside such a field, because it needs a FieldErrorContext; ui-lib's wrapper falls back to rendering the message itself, so the same JSX works next to a bare Checkbox, Switch, or Slider. What does not carry over is the id: outside a field nothing generates one, so the message has to carry id={field.errorId} explicitly.

The mirror image is not dangerous, but it is still wrong. Inside a react-aria field, leave the id off the message. The field generates its own ids for the description and error slots and has already pointed the control's aria-describedby at them, and react-aria reads back whatever id the element ends up carrying, so an explicit id={field.errorId} is announced correctly and buys nothing. What it costs is the reader: an id and an aria-describedby that duplicate wiring the field already did read as load-bearing, and the next person to touch the field cannot tell which half is holding it up. Whoever owns the ids does the wiring: the field inside one, you outside one — and "inside one" means inside its subtree, so a message rendered as a sibling of a Calendar is outside it and carries its own id.

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

Was real code in src/components/conform-checkbox.tsx (the message had no id while the control already referenced one), until it was fixed

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-quebi-danger">{field.errors?.join(", ")}</p>}

do

<Checkbox
  name={field.name}
  isInvalid={hasErrors}
  aria-describedby={describedBy(hasErrors && field.errorId)}
>
  {label}
</Checkbox>
{hasErrors && <FieldError id={field.errorId}>{field.errors?.join(", ")}</FieldError>}

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. The id is explicit because a bare Checkbox is not a react-aria field: nothing generates an id here, and react-aria's own FieldError would render null. ui-lib's FieldError falls back to rendering the message, so the shape is the same one used inside a TextField — but drop the id there, where the field owns it.

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-quebi-danger">{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. The object the errors are read off has to be spelled like a field — `field`, `props.field`, `fields.email`, `fields.address.street` — so `<p>{response.errors}</p>` on a GraphQL response is not a finding; until task #196 it was, and an error whose only way out was a suppression at every call site. What is bought with that is what is lost: a field reached through an alias (an item out of `getFieldList()`, `const emailField = fields.email`) is spelled like a domain object and goes unreported. 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 load it from an `overrides` entry — the one in the file's own header, which lists the paths the rule applies to (app/**/*.{tsx,jsx}, src/**/*.{tsx,jsx}) and the paths its documented exceptions carve back out. Not from the top-level `plugins`: a plugin listed there runs against every file you lint, and an override can add one but cannot take one away. If your project keeps its components somewhere else, edit the `includes` to match.

// 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
//
// This file says what the rule is, not where it applies. A plugin listed in
// the top-level `plugins` runs against every file Biome lints, so scope it
// with an `overrides` entry — Biome resolves `includes` against the project
// root, which is the one thing a $filename regex in here could not do:
//
//   {
//     "overrides": [
//       {
//         "includes": [
//           "app/**/*.{tsx,jsx}",
//           "src/**/*.{tsx,jsx}",
//           "!components/ui/**",
//           "!src/components/**"
//         ],
//         "plugins": [
//           "./ui-lib-rules/render-field-text-through-the-field.grit"
//         ]
//       }
//     ]
//   }

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(),
  $field <: r"^(?:[A-Za-z_$][A-Za-z0-9_$]*\.)*(?:field|fields\.[A-Za-z0-9_$]+(?:\.[A-Za-z0-9_$]+)*)$",
  $children <: not contains `$field.errors.length`,
  $attrs <: not contains JsxAttribute(name = r"^id$"),
  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 '*.{jsx,tsx}' \
  -g '!components/ui/**' \
  -g '!src/components/**' \
  "<(p|span|div)[^>]*>\\s*\\{[a-zA-Z0-9_$.]*\\bfields?\\b[a-zA-Z0-9_$.]*\\.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. The rule is named by its plugin's file name, render-field-text-through-the-field.grit — a suppression that names this rule quiets this rule and no other plugin on the same line, and if the file is renamed Biome reports the comment as having no effect rather than guessing. `lint/plugin` on its own is valid too and quiets every plugin rule on the node at once; name the rule instead.

// biome-ignore lint/plugin/render-field-text-through-the-field: A form-level error summary that belongs to no single field — "Your session expired, please sign in again" is not a field error.

// Between JSX children, where a comment has to be an expression, the same
// line is written {/* biome-ignore … */}.

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