A single input frequently needs three descriptions at once β a persistent format hint, a validation error, and a live character count β and aria-describedby is the only attribute that carries all three, but only if you treat its value as an ordered token list rather than a string you overwrite.
The bug this page fixes: your error message reaches the screen reader, but the format hint silently disappears, because the code called input.setAttribute('aria-describedby', errorId) and clobbered the two ids that were already there. aria-describedby accepts a space-separated list of ids, and every subsystem that wants to describe the field must add and remove only its own token without disturbing the others.
This pattern sits underneath the ARIA live regions for form errors work: the live region announces the change, while aria-describedby provides the descriptions a screen reader reads when the user navigates back onto the field. You need both, and they reference the same error node.
The token-list model
aria-describedby is an IDREF list. Its value is any number of element ids separated by ASCII spaces. When focus lands on the input, assistive technology looks up each id in order, gathers the text content of each referenced element, and reads them as one description string. Three consequences follow directly:
- Order in the token list controls announcement order β not DOM order, not source order.
describedby="hint err count"reads hint β error β count regardless of where those nodes sit on the page. - A dangling id is not an error but it is a defect: the browser skips ids that resolve to nothing, so a stale error id whose element you removed produces a silent gap or, worse, reads a hidden empty node on some AT.
- Every writer must be additive. The hint is written once at render. The validator adds and removes the error id. The counter adds its id when the field has a maxlength. None may overwrite the others.
Core implementation
The whole pattern reduces to two safe primitives β add a token, remove a token β that read the current list, mutate only their own id, deduplicate, and write back. Everything else builds on them.
/**
* Read aria-describedby into an ordered, de-duplicated token array.
* Splitting on /\s+/ and filtering Boolean drops the empty string that
* results from a missing attribute or stray double spaces.
*/
function getDescribedBy(el: HTMLElement): string[] {
const raw = el.getAttribute("aria-describedby") ?? "";
return raw.split(/\s+/).filter(Boolean);
}
/**
* Write tokens back, or remove the attribute entirely when the list is
* empty. An empty aria-describedby="" is technically valid but some AT
* still tries to resolve it, so removing it is cleaner.
*/
function setDescribedBy(el: HTMLElement, tokens: string[]): void {
if (tokens.length === 0) {
el.removeAttribute("aria-describedby");
} else {
el.setAttribute("aria-describedby", tokens.join(" "));
}
}
/**
* Add `id` to the list if absent. `position` controls announcement order:
* "end" for volatile state (counter), or an explicit index for the error,
* which must sit after the hint but before the counter.
*/
function addDescribedBy(el: HTMLElement, id: string, index?: number): void {
const tokens = getDescribedBy(el);
if (tokens.includes(id)) return; // idempotent β never duplicate a token
if (index === undefined || index >= tokens.length) {
tokens.push(id);
} else {
tokens.splice(index, 0, id);
}
setDescribedBy(el, tokens);
}
/** Remove only the caller's own id; foreign tokens are untouched. */
function removeDescribedBy(el: HTMLElement, id: string): void {
const tokens = getDescribedBy(el).filter((t) => t !== id);
setDescribedBy(el, tokens);
}
Now the field-level orchestration. A field owns up to three description ids with a fixed priority so the read order is deterministic regardless of which writer fires first:
interface DescriptionIds {
hint?: string; // priority 0 β persistent format guidance
error?: string; // priority 1 β current validation error
count?: string; // priority 2 β live character count
}
const PRIORITY: (keyof DescriptionIds)[] = ["hint", "error", "count"];
/**
* Rebuild aria-describedby from a declarative "which descriptions are
* active" object, always emitting tokens in PRIORITY order. This is the
* safe alternative to hand-toggling when a field's own descriptions are
* fully owned by one controller. Foreign tokens (from a fieldset, say)
* are preserved by prepending anything not in `ids`.
*/
function syncFieldDescriptions(
input: HTMLElement,
ids: DescriptionIds,
active: { hint: boolean; error: boolean; count: boolean }
): void {
const owned = new Set(Object.values(ids).filter(Boolean) as string[]);
const foreign = getDescribedBy(input).filter((t) => !owned.has(t));
const mine: string[] = [];
for (const key of PRIORITY) {
const id = ids[key];
if (id && active[key]) mine.push(id);
}
// Foreign tokens keep their leading position; ours follow in priority.
setDescribedBy(input, [...foreign, ...mine]);
}
Step-by-step walkthrough
- Assign stable ids at render. Each description element gets a deterministic id derived from the field name:
pw-hint,pw-err,pw-count. Deterministic ids let the validator, the counter, and any test reference the same node without querying the DOM. - Write the hint once. On mount,
addDescribedBy(input, 'pw-hint', 0). The hint is persistent, so it is never removed and always occupies index 0. - Toggle the error on validation. When validation fails,
addDescribedBy(input, 'pw-err', 1)β index 1 places it after the hint. When the field becomes valid,removeDescribedBy(input, 'pw-err'). Only the error token moves; the hint and counter stay put. - Append the counter last. The live character count is the most volatile description, so it is appended with
addDescribedBy(input, 'pw-count')(no index β end of list). It is read last, after guidance the user actually needs first. - Read back to verify order. After any mutation,
getDescribedBy(input)returns["pw-hint", "pw-err", "pw-count"]. That array is the announcement order.
Because every writer goes through addDescribedBy / removeDescribedBy, no writer can clobber another. The declarative syncFieldDescriptions is the alternative when one controller owns all three descriptions and you would rather express intent than sequence mutations.
Failure modes and edge cases
setAttribute clobbers foreign tokens
The classic regression: input.setAttribute('aria-describedby', errorId) overwrites the entire list, dropping the hint and counter ids. This is why the primitives above always read-merge-write.
// WRONG β destroys hint and count tokens
input.setAttribute("aria-describedby", "pw-err");
// RIGHT β additive, preserves the rest
addDescribedBy(input, "pw-err", 1);
Duplicate ids after re-validation
Calling addDescribedBy on every keystroke without the includes guard appends pw-err pw-err pw-err. Some AT reads the description three times. The if (tokens.includes(id)) return line makes the operation idempotent β always keep it.
A stale id whose element was unmounted
If a conditional error node is removed from the DOM but its id lingers in aria-describedby, the browser resolves nothing and most screen readers skip it β but VoiceOver on older Safari has been observed reading an empty description. Always removeDescribedBy in the same code path that unmounts the node.
Character-count updates spamming the description
The counter node updating on every keystroke is fine for aria-describedby (it is read on focus, not on change) but becomes a firehose if that same node is also an aria-live="polite" region. Keep the counterβs live announcements throttled β see aria-invalid timing and screen reader announcements for the debounce coordination that prevents mid-typing chatter.
Hidden hint text still gets announced
An element referenced by aria-describedby is announced even if it is visually hidden with .sr-only β that is intentional. But display:none or hidden on the referenced node suppresses the description entirely. Use a clipping utility (position:absolute; clip-path), never display:none, for descriptions you want read but not shown.
Verification checklist
Frequently Asked Questions
Does the order of ids in aria-describedby change what the screen reader announces?
Yes. Assistive technology concatenates the referenced elements in the order the ids appear in the token list, not in DOM order. Put the persistent hint first, the error second, and a volatile character count last so the most stable guidance is read before transient state. This is why the implementation inserts the error at index 1 rather than pushing it to the end.
Should I remove the error id from aria-describedby when the field becomes valid?
Remove the error token but keep its element in the DOM if you use a live region to announce clearing. Leaving a stale error id in the token list makes the screen reader read an empty or hidden node when the user navigates back onto the field, which sounds like a glitch. Toggle only the token you own with removeDescribedBy and never rebuild the whole attribute.
Why did my aria-describedby lose the hint id when I set the error?
You assigned the attribute with a single string instead of merging into the existing token list. setAttribute overwrites the whole value, so writing the error id alone clobbers the hint and counter ids. Read the current tokens with getDescribedBy, add yours, deduplicate, and write the merged list back β that is exactly what addDescribedBy does.