Barrierefreie Formulare bauen: jenseits von semantischem HTML
Über die Formularsemantik hinaus: Accessibility-Muster für Validierung, Fehlerbehandlung, dynamische Felder und mehrstufige Formulare für alle.

Semantisches HTML liefert 60 % der Formular-Accessibility kostenlos mit. Die Elemente <label>, <fieldset> und <input> leisten schwere Arbeit, die ARIA-Attribute niemals vollständig ersetzen können. Aber Formulare in der Produktion brauchen Validierungsmeldungen, dynamische Felder, bedingte Abschnitte und mehrstufige Navigation, die semantisches HTML allein nicht abdecken kann.
Die verbleibenden 40 % erfordern bewusstes Engineering: korrekte ARIA-Attribute, Fokusmanagement, Ansagen über Live-Regionen und Tastatur-Navigationsmuster, die die meisten Komponentenbibliotheken falsch umsetzen.
Das Fundament: Labels und Zuordnungen
Jedes Input-Feld braucht einen zugänglichen Namen. Das <label>-Element stellt diesen bereit, aber die Zuordnung muss explizit sein – bloße Nähe funktioniert für Screenreader nicht.
<!-- ❌ Implicit association only works visually -->
<div>
Email
<input type="email" />
</div>
<!-- ❌ Placeholder as label: disappears on focus -->
<input type="email" placeholder="Email address" /><!-- ✅ Explicit label association -->
<div>
<label for="user-email">Email address</label>
<input
type="email"
id="user-email"
name="email"
autocomplete="email"
required
aria-describedby="email-hint"
/>
<p id="email-hint" class="hint-text">
We'll use this for account recovery only.
</p>
</div>Das Attribut aria-describedby verknüpft ergänzende Informationen mit dem Input. Ein Screenreader kündigt an: "Email address, edit, required. We'll use this for account recovery only." Der Hinweistext liefert Kontext, ohne das Label zu überladen.
Fehlerbehandlung, die Screenreader verstehen
Bei Validierungsfehlern scheitern die meisten Formulare in Sachen Accessibility. Sehende Nutzer sehen roten Text; Screenreader-Nutzer brauchen programmatische Zuordnungen und Ansagen.
<!-- ❌ Error message not associated with input -->
<label for="password">Password</label>
<input type="password" id="password" />
<span class="error" style="color: red;">
Password must be at least 8 characters
</span><!-- ✅ Error properly associated and announced -->
<label for="password">Password</label>
<input
type="password"
id="password"
aria-invalid="true"
aria-describedby="password-error password-hint"
aria-required="true"
/>
<p id="password-error" class="error" role="alert">
Password must be at least 8 characters
</p>
<p id="password-hint" class="hint">
Include uppercase, lowercase, and a number.
</p>// React component with proper error handling
interface FormFieldProps {
id: string;
label: string;
type: string;
error?: string;
hint?: string;
required?: boolean;
value: string;
onChange: (value: string) => void;
}
function FormField({
id,
label,
type,
error,
hint,
required,
value,
onChange,
}: FormFieldProps) {
const errorId = `${id}-error`;
const hintId = `${id}-hint`;
const describedBy = [
error ? errorId : null,
hint ? hintId : null,
]
.filter(Boolean)
.join(" ");
return (
<div className="form-field">
<label htmlFor={id}>
{label}
{required && <span aria-hidden="true"> *</span>}
</label>
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-invalid={error ? "true" : undefined}
aria-describedby={describedBy || undefined}
aria-required={required}
/>
{error && (
<p id={errorId} className="error" role="alert">
{error}
</p>
)}
{hint && (
<p id={hintId} className="hint">
{hint}
</p>
)}
</div>
);
}Das role="alert" auf der Fehlermeldung erzeugt eine Live-Region, die Screenreader sofort ansagen, sobald sie erscheint. Das Attribut aria-invalid="true" signalisiert assistiven Technologien, dass das Feld Aufmerksamkeit braucht.
Fokusmanagement bei der Validierung
Wenn ein Formular mehrere Fehler enthält, brauchen Nutzer einen klaren Weg, um sie zu beheben. Das Fokusmanagement führt sie systematisch durch jeden Fehler.
// ❌ No focus management: user doesn't know where to start
function handleSubmit(errors: Map<string, string>) {
if (errors.size > 0) {
// Errors shown visually, but no focus guidance
setErrors(errors);
}
}// ✅ Focus the first error field and provide summary
function handleSubmitAccessible(
errors: Map<string, string>,
formRef: React.RefObject<HTMLFormElement | null>
) {
if (errors.size === 0) return;
// Update error state
setErrors(errors);
// Wait for DOM update, then focus error summary
requestAnimationFrame(() => {
const summary = formRef.current?.querySelector(
"[data-error-summary]"
) as HTMLElement | null;
if (summary) {
summary.focus();
} else {
// Fallback: focus first invalid field
const firstError = formRef.current?.querySelector(
"[aria-invalid='true']"
) as HTMLElement | null;
firstError?.focus();
}
});
}
// Error summary component
function ErrorSummary({ errors }: { errors: Map<string, string> }) {
if (errors.size === 0) return null;
return (
<div
data-error-summary
role="alert"
tabIndex={-1}
className="error-summary"
>
<h2>There are {errors.size} errors in your submission</h2>
<ul>
{Array.from(errors.entries()).map(([fieldId, message]) => (
<li key={fieldId}>
<a href={`#${fieldId}`}>{message}</a>
</li>
))}
</ul>
</div>
);
}Die Fehlerzusammenfassung verlinkt direkt auf jedes problematische Feld. Ein Klick auf einen Link fokussiert das entsprechende Input-Feld und gibt den Nutzern einen klaren Weg durch alle Fehler.
Dynamische Felder und Live-Ansagen
Formulare mit Feldern zum Hinzufügen/Entfernen, bedingten Abschnitten oder Ladezuständen brauchen Live-Regionen, um Änderungen anzusagen, die sehende Nutzer visuell wahrnehmen.
function DynamicFieldList({
fields,
onAdd,
onRemove,
}: {
fields: string[];
onAdd: () => void;
onRemove: (index: number) => void;
}) {
const [announcement, setAnnouncement] = useState("");
function handleAdd() {
onAdd();
setAnnouncement(
`Item ${fields.length + 1} added. ${fields.length + 1} items total.`
);
}
function handleRemove(index: number) {
onRemove(index);
setAnnouncement(
`Item ${index + 1} removed. ${fields.length - 1} items total.`
);
// Focus the previous field or the add button
requestAnimationFrame(() => {
const prevField = document.getElementById(
`field-${Math.max(0, index - 1)}`
);
if (prevField) {
prevField.focus();
}
});
}
return (
<fieldset>
<legend>Phone numbers</legend>
{/* Screen-reader-only live region */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
{fields.map((field, index) => (
<div key={index} className="dynamic-field">
<label htmlFor={`field-${index}`}>
Phone number {index + 1}
</label>
<input
id={`field-${index}`}
type="tel"
defaultValue={field}
autoComplete="tel"
/>
<button
type="button"
onClick={() => handleRemove(index)}
aria-label={`Remove phone number ${index + 1}`}
>
Remove
</button>
</div>
))}
<button type="button" onClick={handleAdd}>
Add phone number
</button>
</fieldset>
);
}Die Region aria-live="polite" kündigt Änderungen an, ohne den aktuellen Kontext des Nutzers zu unterbrechen. "assertive" würde sofort unterbrechen – für Fehler angemessen, aber für routinemäßige Aktualisierungen zu aggressiv.
Navigation in mehrstufigen Formularen
Mehrstufige Formulare brauchen eine klare Fortschrittsanzeige, per Tastatur navigierbare Schritte und eine saubere Überschriftenhierarchie innerhalb jedes Schritts.
interface Step {
id: string;
label: string;
completed: boolean;
}
function StepIndicator({
steps,
currentStep,
}: {
steps: Step[];
currentStep: number;
}) {
return (
<nav aria-label="Form progress">
<ol className="step-indicator">
{steps.map((step, index) => (
<li
key={step.id}
aria-current={index === currentStep ? "step" : undefined}
>
<span className="step-number" aria-hidden="true">
{step.completed ? "✓" : index + 1}
</span>
<span className={index === currentStep ? "current" : ""}>
{step.label}
{step.completed && (
<span className="sr-only"> (completed)</span>
)}
{index === currentStep && (
<span className="sr-only"> (current step)</span>
)}
</span>
</li>
))}
</ol>
</nav>
);
}
function MultiStepForm({ steps }: { steps: Step[] }) {
const [currentStep, setCurrentStep] = useState(0);
const stepRef = useRef<HTMLDivElement>(null);
function navigateToStep(newStep: number) {
setCurrentStep(newStep);
// Focus the step heading after navigation
requestAnimationFrame(() => {
const heading = stepRef.current?.querySelector("h2");
heading?.focus();
});
}
return (
<form>
<StepIndicator steps={steps} currentStep={currentStep} />
<div ref={stepRef}>
<h2 tabIndex={-1}>
Step {currentStep + 1} of {steps.length}:{" "}
{steps[currentStep].label}
</h2>
{/* Step content renders here */}
</div>
<div className="step-navigation">
{currentStep > 0 && (
<button
type="button"
onClick={() => navigateToStep(currentStep - 1)}
>
Back to {steps[currentStep - 1].label}
</button>
)}
{currentStep < steps.length - 1 ? (
<button
type="button"
onClick={() => navigateToStep(currentStep + 1)}
>
Continue to {steps[currentStep + 1].label}
</button>
) : (
<button type="submit">Submit form</button>
)}
</div>
</form>
);
}Das Fokussieren der Schrittüberschrift bei der Navigation orientiert Screenreader-Nutzer innerhalb des Formulars. Ohne diesen Schritt landen sie im Inhalt des neuen Schritts, ohne Kontext darüber, was sich geändert hat.
Accessibility-Tests in der Praxis
Automatisierte Tools erkennen etwa 30 % der Accessibility-Probleme. Der Rest erfordert manuelle Tests mit echter assistiver Technologie.
// Automated testing with jest and testing-library
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
describe("FormField accessibility", () => {
it("associates error message with input", () => {
render(
<FormField
id="email"
label="Email"
type="email"
error="Invalid email address"
value=""
onChange={() => {}}
/>
);
const input = screen.getByLabelText("Email");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAccessibleDescription("Invalid email address");
});
it("moves focus to error summary on submit", async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.click(screen.getByRole("button", { name: /submit/i }));
const summary = screen.getByRole("alert");
expect(summary).toHaveFocus();
});
it("announces dynamic field additions", async () => {
const user = userEvent.setup();
render(<DynamicFieldList fields={["555-0100"]} onAdd={() => {}} onRemove={() => {}} />);
await user.click(
screen.getByRole("button", { name: /add phone/i })
);
expect(screen.getByText(/2 items total/i)).toBeInTheDocument();
});
});Die wichtigsten Erkenntnisse
Barrierefreie Formulare gehen weit über das Hinzufügen von <label>-Elementen hinaus. Formulare in der Produktion brauchen Fehlerzuordnungen über aria-describedby, Ansagen über Live-Regionen für dynamische Änderungen, Fokusmanagement, das Nutzer durch Validierungsfehler führt, und mehrstufige Navigation, die orientiert statt desorientiert. Jede Formularinteraktion, die ein sehender Nutzer visuell wahrnimmt, muss ein programmatisches Äquivalent für assistive Technologien haben.
Testen Sie vor dem Ausliefern mindestens einmal mit einem Screenreader. VoiceOver (macOS), NVDA (Windows) oder JAWS decken Probleme auf, die kein Linting-Tool finden kann. Die fünf Minuten, die Sie damit verbringen, mit einem Screenreader durch Ihr Formular zu tabben, ersparen Nutzern, die täglich darauf angewiesen sind, stundenlange Frustration.


