Label associations, placeholder-vs-label, help text via aria-describedby, and inline validation with a polite live region — in Vanilla and React.
WCAG: 1.3.1 · 1.4.11 · 3.3.1 · 3.3.2 · 4.1.2
Level: Beginner → Intermediate
Icons: lucide-react (CheckCircle2, AlertCircle)
1
What we are building
A reusable input that has a real label, a programmatic hint, and friendly validation.
What we are building
An input is the fundamental form control. Four things decide whether it is accessible: a programmatic label, the correct input type (so mobile keyboards and autofill work), help text linked via aria-describedby, and validation that runs on blur (not on every keystroke) and announces politely.
2
The 4 real-world variants
Switch between the inaccessible version and the accessible fix.
The 4 real-world variants
Variant A
Label associations
Preferred
Live example
3. aria-labelledby (for existing headings)
All three expose the same accessible name — “edit text” with the label text. Prefer explicit for/id.
HTML · Accessible
<!-- Three valid associations — pick based on your markup -->
<!-- 1. Explicit for/id (preferred) -->
<label for="email">Email</label>
<input id="email" type="email" autocomplete="email">
<!-- 2. Wrapped label -->
<label>
Email
<input type="email" autocomplete="email">
</label>
<!-- 3. aria-labelledby — when a visible heading already exists -->
<h3 id="shipping-title">Shipping address</h3>
<input type="text" aria-labelledby="shipping-title" autocomplete="street-address">
Three valid patterns: explicit for/id, wrapped label, aria-labelledby. Prefer the explicit for/id in new code.
Variant B
Placeholder as label (anti)
Critical fix
Live example
Focus the input in each mode — the ❌ version loses its label as soon as you start typing.
HTML · Accessible
<!-- Visible <label> + placeholder as a FORMAT HINT only -->
<label for="email">Email address</label>
<input
id="email"
type="email"
placeholder="you@example.com" <!-- format hint, not the label -->
autocomplete="email"
>
Placeholders disappear on focus, often fail contrast, and are not spoken as the label by every SR. Use a visible label.
Variant C
Help text via aria-describedby
Preferred
Live example
SR reads: “Create a password, edit, protected, At least 8 characters, including a number”.
HTML · Accessible
<!-- Help text linked via aria-describedby — SR reads it after the label -->
<label for="pw">Password</label>
<input
id="pw"
type="password"
aria-describedby="pw-help"
minlength="8"
autocomplete="new-password"
>
<span id="pw-help">
At least 8 characters, including a number.
</span>
Visible hint linked to the input so SR reads label + hint together. Use for format rules, examples, privacy notes.
Variant D
Inline validation (on blur)
Friendly
Live example
Type and Tab away — validation runs on blur, not keystroke. SR polite-announces the result.
JAVASCRIPT · Accessible
// Validate on blur. Announce politely so SR doesn't interrupt.
const err = document.getElementById('email-status');
input.addEventListener('blur', () => {
if (input.value === '') {
input.setAttribute('aria-invalid', 'true');
err.textContent = 'Email is required.';
return;
}
if (!/^\S+@\S+\.\S+$/.test(input.value)) {
input.setAttribute('aria-invalid', 'true');
err.textContent = 'Enter an address like you@example.com.';
return;
}
input.removeAttribute('aria-invalid');
err.textContent = 'Looks good.';
});
// HTML:
// <input id="email" type="email" aria-describedby="email-status" ...>
// <span id="email-status" role="status" aria-live="polite"></span>
Validate on blur, not keystroke. Use role=status / aria-live=polite so SR announces the result without interrupting.
3
Flagship implementation
A reusable AccessibleInput with label, help, blur validation and live status.