Accessibility Developer Kit · Guide 13 / 20

Accessible Input

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.

Flagship implementation

AccessibleInput.jsx
import React, { useId, useState } from "react";
import "./AccessibleInput.css";

/**
 * AccessibleInput
 *   - label:         visible label (required)
 *   - help:          help text (optional, linked via aria-describedby)
 *   - validate:      function (value) => string | null (error message)
 *   - type, autocomplete, placeholder, required, ...rest
 */
export default function AccessibleInput({
  label,
  help,
  validate,
  type = "text",
  value,
  onChange,
  autoComplete,
  required = false,
  placeholder,
  ...rest
}) {
  const id = useId();
  const helpId = `${id}-help`;
  const errId = `${id}-err`;
  const [touched, setTouched] = useState(false);
  const error = touched && validate ? validate(value) : null;

  const describedBy = [
    help ? helpId : null,
    error ? errId : null,
  ].filter(Boolean).join(" ") || undefined;

  return (
    <label className="field" htmlFor={id}>
      <span className="field__label">
        {label}
        {required && <span className="req" aria-hidden="true"> *</span>}
      </span>
      <input
        id={id}
        type={type}
        value={value}
        onChange={onChange}
        onBlur={() => setTouched(true)}
        required={required}
        autoComplete={autoComplete}
        placeholder={placeholder}
        aria-invalid={!!error || undefined}
        aria-describedby={describedBy}
        className="field__input"
        {...rest}
      />
      {help && <span id={helpId} className="field__help">{help}</span>}
      <span
        id={errId}
        role="status"
        aria-live="polite"
        className="field__err"
      >
        {error || ""}
      </span>
    </label>
  );
}
Usage · SignupForm.jsx
import { useState } from "react";
import AccessibleInput from "./AccessibleInput";

export default function SignupForm() {
  const [email, setEmail] = useState("");

  return (
    <AccessibleInput
      label="Email address"
      type="email"
      autoComplete="email"
      required
      help="We'll never share your email."
      placeholder="you@example.com"
      value={email}
      onChange={(e) => setEmail(e.target.value)}
      validate={(v) => {
        if (!v) return "Email is required.";
        if (!/^\S+@\S+\.\S+$/.test(v))
          return "Enter an address like you@example.com.";
        return null;
      }}
    />
  );
}
4

Keyboard behavior

Native inputs get this right — don't break it.

Keyboard behavior

ActionExpected result
Tab / Shift+TabMoves between inputs
Click labelFocuses the associated input
Enter (inside a form)Submits the form
BlurTriggers inline validation
5

Screen reader expectations

A well-labeled input announces label + state + hint + current value.

Screen reader expectations

  • Empty: “Email address, edit, blank, We’ll never share your email”
  • Required: “Email address, required, edit, blank”
  • Invalid: “Email address, invalid, edit, Enter an address like you@example.com”
  • Password: “Create a password, edit, protected, At least 8 characters, including a number”
6

WCAG mapping

Why each rule matters here.

WCAG mapping

Success CriterionWhy it matters
1.3.1 Info and Relationships (A)Label must be programmatically associated with the input.
1.4.11 Non-text Contrast (AA)Input borders, placeholder, and focus ring must have 3:1 contrast.
3.3.1 Error Identification (A)Errors must be in text, not color alone.
3.3.2 Labels or Instructions (A)Every input needs a visible label; hints should be visible.
4.1.2 Name, Role, Value (A)Exposes label + role=textbox + value + required/invalid state.
7

Testing approach

Framework-agnostic patterns. Pick the one that matches your stack.

Testing approach

Pattern: type → blur → assert aria-invalid + live-region text → fix → assert aria-invalid gone.

Jest + Testing Library + jest-axe

AccessibleInput.test.jsx
// Jest + @testing-library/react + jest-axe
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import AccessibleInput from "./AccessibleInput";

expect.extend(toHaveNoViolations);

function Fixture() {
  const [v, setV] = useState("");
  return (
    <AccessibleInput
      label="Email"
      type="email"
      value={v}
      onChange={(e) => setV(e.target.value)}
      help="We'll never share your email."
      validate={(x) => (/^\S+@\S+\.\S+$/.test(x) ? null : "Enter a valid email.")}
    />
  );
}

test("input has an accessible name and description", async () => {
  render(<Fixture />);
  const input = screen.getByRole("textbox", { name: "Email" });
  expect(input).toHaveAccessibleDescription(/share your email/i);
});

test("announces error on blur, clears on fix", async () => {
  const user = userEvent.setup();
  render(<Fixture />);
  const input = screen.getByRole("textbox", { name: "Email" });
  await user.type(input, "nope");
  await user.tab();
  expect(input).toHaveAttribute("aria-invalid", "true");
  expect(screen.getByRole("status")).toHaveTextContent(/enter a valid email/i);
  await user.clear(input);
  await user.type(input, "you@example.com");
  await user.tab();
  expect(input).not.toHaveAttribute("aria-invalid");
});

test("has no axe violations", async () => {
  const { container } = render(<Fixture />);
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library

AccessibleInput.test.jsx
// Vitest + @testing-library/react
import { expect, test } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AccessibleInput from "./AccessibleInput";

test("placeholder is NOT the accessible name", () => {
  function F() {
    const [v, setV] = useState("");
    return (
      <AccessibleInput
        label="Email"
        value={v}
        onChange={(e) => setV(e.target.value)}
        placeholder="you@example.com"
      />
    );
  }
  render(<F />);
  // Visible label wins over placeholder:
  expect(screen.getByRole("textbox", { name: "Email" })).toBeInTheDocument();
});

Playwright + @axe-core/playwright

input.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("input page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-input");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("live-validation announces on blur, not on keystroke", async ({ page }) => {
  await page.goto("/en/accessible-input");
  const input = page.getByLabel(/email address/i).first();
  await input.fill("nope");
  // No announcement yet — still typing
  await expect(page.locator('[role="status"].is-err')).toHaveCount(0);
  await input.blur();
  await expect(page.locator('[role="status"].is-err')).toContainText(/enter an address/i);
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Every input has a visible <label for="...">
  • Correct type (email / tel / url / number / password / search)
  • autocomplete set when applicable
  • Placeholder is NOT the label — only a format hint
  • Help text linked via aria-describedby
  • Visible focus ring (3:1 contrast)
  • Minimum 44×44 target size
  • Validation runs on blur, not keystroke
  • Error text uses role="status" or linked via aria-describedby + aria-invalid
  • Zero critical / serious axe violations
9

Common mistakes

The input bugs we see on nearly every audit.

Common mistakes

  • Placeholder as label. Disappears on focus; often fails contrast.
  • No autocomplete. Users retype every signup.
  • Help text not linked. SR reads label only; hint invisible to AT.
  • Validation on every keystroke. “Invalid” after one letter is cruel.
  • Error only as red border. Fails 1.4.1 and 3.3.1 — add text.
  • Wrong input type. type="text" on email means no mobile keyboard, no autocomplete, no browser validation.
10

Download & next

Grab the code bundle, or jump to the next guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo