Every control needs an accessible name, and the cheapest one is a real <label htmlFor>. The React-specific problem is the id: a reusable <Field> can appear five times on a page, so a hard-coded id="email" gives five duplicates and four broken labels. useId solves it — unique per component instance and identical on server and client, so it survives hydration. Never use it as a list key.
ARIA fills the gaps HTML cannot express: aria-describedby attaches hint and error text without folding it into the name, and aria-invalid marks the field as failing. Both take ID references built from that same useId value. Pass undefined, not false or '', for an attribute that should not be emitted.
function Field({ label, hint, error, ...rest }) {
const id = useId(), hintId = `${id}-hint`, errId = `${id}-err`;
return <p>
<label htmlFor={id}>{label}</label><br />
<input id={id} aria-invalid={error ? true : undefined}
aria-describedby={[hint && hintId, error && errId]
.filter(Boolean).join(' ') || undefined} {...rest} />
{hint && <span id={hintId}> {hint}</span>}
{error && <span id={errId} style={{ color: '#b3261e' }}> {error}</span>}
</p>;
}Render two — one with a hint, one submitted empty — and both strings arrive as the field's description, apart from its name. Chromium 4,389 's tree prints the second field as textbox "Email" invalid=true desc="Enter a work email.", which is exactly what a screen reader reads out: the name first, the state, then the description.
aria-label on a visible control replaces that text for screen reader users and breaks voice control, which matches spoken words against what is on screen; reserve it for icon-only buttons, and mark the icon aria-hidden="true". role is a last resort: role="button" on a <div> promises keyboard behavior you must then write by hand. Name precedence runs aria-labelledby, aria-label, <label>, placeholder.