Accessible Login Form in React: Labels, Errors, Motion

Updated 8 min read

TL;DR An accessible login form in React uses a visible label on every input, links each error to its field with aria-describedby, announces sign-in failures from an always-mounted live region, moves focus to the first broken field, and turns its shake animation off under prefers-reduced-motion.

On this page
  1. Why do placeholder-only login forms fail accessibility?
  2. How do I connect an error message to its input?
  3. How do screen readers hear a failed sign-in?
  4. Move focus to the first field that needs fixing
  5. How do I make a show/hide password button accessible?
  6. A shake animation that respects prefers-reduced-motion
  7. How to test an accessible login form with a keyboard only
  8. Adding more animation without breaking access
  9. Common mistakes in generated login forms
  10. What changes with a Server Action?
  11. FAQ
  12. Is a show/hide password button accessible?
  13. Should form errors go in an aria-live region or use aria-describedby?
  14. Can I use aria-live with React Hook Form errors?
  15. How do I show and hide a password in React?

An accessible login form in React needs five things, animated or not: a visible <label> tied to every input, error messages linked with aria-describedby and announced through a live region, focus moved to the first broken field when submit fails, a show/hide password button that says what it does, and an animation that switches off when the user has asked for reduced motion. Get those right and you can keep the shake and the transitions, because the motion stops being the only way the form talks.

Below is the form I use as a base, written for a Next.js App Router project with React 19 and Tailwind v4. I built it in a fresh create-next-app project, and it passes tsc --noEmit and next build. Each section explains one decision.

Why do placeholder-only login forms fail accessibility?

Placeholders that pretend to be labels are the most common problem in generated login forms. They disappear when you type, they are often low contrast, and assistive tech handles them inconsistently. Use a <label htmlFor> for every field.

tsx
<label htmlFor={`${id}-email`} className="block text-sm font-medium">
  Email
</label>
<input
  ref={emailRef}
  id={`${id}-email`}
  name="email"
  type="email"
  autoComplete="username"
  inputMode="email"

A few details in there matter more than they look:

  • useId() gives each instance unique ids, so two forms on one page (a modal and a page, say) don't collide. It produces the same id on the server and the client, so it doesn't cause hydration mismatches. See the React docs for useId.
  • autoComplete="username" on the email field and autoComplete="current-password" on the password let password managers fill both. On a sign-up form you'd use new-password instead. The full list of tokens is on MDN's autocomplete page.
  • type="email" plus inputMode="email" gets mobile users the keyboard with the @ key.

I also put noValidate on the form. The browser's built-in bubbles are accessible, but they can't be styled and they appear one at a time. If you turn them off, you take on the job of announcing errors yourself, which is what the next part does.

Shy Eyes Login running live. A login mascot that watches you type and covers his eyes for the password.

How do I connect an error message to its input?

When a field fails, three things should happen: the input gets aria-invalid, the message appears as text next to it, and the input points at that text through aria-describedby. A screen reader then reads the label, the invalid state and the message when focus lands on the field.

tsx
  aria-invalid={errors.email ? true : undefined}
  aria-describedby={errors.email ? emailErrorId : undefined}
  className="w-full rounded-lg border border-zinc-300 px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 aria-[invalid=true]:border-red-600"
/>
{errors.email && (
  <p id={emailErrorId} className="text-sm text-red-700">
    {errors.email}
  </p>
)}

The password field has a permanent hint ("At least 8 characters"), so its aria-describedby always includes the hint id and puts the error id in front of it when there is an error. You can list several ids separated by spaces, and they are read in that order. See MDN on aria-describedby.

tsx
aria-describedby={
  errors.password ? `${passwordErrorId} ${passwordHintId}` : passwordHintId
}

Tailwind v4's aria-[invalid=true]: variant styles the red border straight from the attribute, so the visual state and the accessible state can't drift apart.

How do screen readers hear a failed sign-in?

Field errors are found when the user moves to the field. A failed sign-in ("that email and password don't match") belongs to the whole form, and the user needs to hear it right away. That's a job for a live region:

tsx
{/* Always rendered, so screen readers pick up changes to its text. */}
<p role="alert" aria-live="assertive" className="min-h-5 text-sm text-red-700">
  {errors.form ?? ""}
</p>

The element is in the DOM from the first render and only its text changes. People often get this wrong: if you mount a role="alert" element at the same moment you fill it, some screen reader and browser pairs miss the announcement. role="alert" already implies aria-live="assertive". I keep both because it costs nothing and makes the intent obvious to whoever reads the code next. More in MDN's guide to live regions.

Write messages that say how to fix the problem. "Enter an email like [email protected]" helps more than "Invalid email".

Move focus to the first field that needs fixing

After a failed submit, focus stays on the submit button unless you move it, and a keyboard user has to tab backwards to find the problem. The fix is two refs and one line:

tsx
setErrors(next);
if (next.email || next.password) {
  setShaking(true);
  // Move focus to the first field that needs fixing.
  (next.email ? emailRef : passwordRef).current?.focus();
  return;
}

Because the input's aria-describedby already points at its message, moving focus there makes the screen reader read the label and the error together. You don't need a second announcement for field errors.

How do I make a show/hide password button accessible?

The eye-icon toggle is usually an icon with no name, so a screen reader announces "button" and nothing else. Mine uses visible text plus a visually hidden word, so it reads "Show password" or "Hide password":

tsx
<button
  type="button"
  onClick={() => setShowPassword((v) => !v)}
  aria-controls={`${id}-password`}
  aria-pressed={showPassword}
  className="absolute inset-y-1 right-1 rounded-md px-3 text-sm font-medium text-indigo-700 focus-visible:outline-2 focus-visible:outline-indigo-500"
>
  {showPassword ? "Hide" : "Show"}
  <span className="sr-only"> password</span>
</button>
  • type="button" stops it from submitting the form. A <button> inside a <form> submits by default.
  • aria-pressed exposes it as a toggle, so its state is announced.
  • aria-controls points at the input it changes.
  • It sits inside the input's box visually but comes right after the password field in the tab order, so a keyboard user reaches it without any tricks.

If you prefer an icon, keep the sr-only text and mark the icon aria-hidden.

A shake animation that respects prefers-reduced-motion

A short horizontal shake is a nice signal that something failed. It shouldn't be the only signal (the text messages do that work), and it should stop for people who turned on "reduce motion" in their OS. The CSS lives in globals.css:

css
@keyframes shake {
  0%, 100% { transform: translateX(0); }
  20%, 60% { transform: translateX(-6px); }
  40%, 80% { transform: translateX(6px); }
}

.shake {
  animation: shake 0.4s ease-in-out;
}

@media (prefers-reduced-motion: reduce) {
  .shake {
    animation: none;
  }
}

In the component, a boolean adds the class and onAnimationEnd removes it, so the next failed submit can play it again:

tsx
<form
  noValidate
  onSubmit={onSubmit}
  onAnimationEnd={() => setShaking(false)}
  aria-labelledby={`${id}-title`}
  className={`mx-auto w-full max-w-sm space-y-5 rounded-2xl border border-zinc-200 bg-white p-6 text-zinc-900 shadow-sm ${
    shaking ? "shake" : ""
  }`}
>

My first version restarted the animation by changing the form's key. That remounts the form and wipes what the user typed, so avoid it.

When reduced motion is on, the animation: none rule wins and nothing moves. The submit button's press effect uses Tailwind's motion-reduce: variant for the same reason. The media query is documented on MDN: prefers-reduced-motion. If you'd rather run the shake with Motion (Framer Motion), its useReducedMotion() hook reads the same setting in JavaScript.

How to test an accessible login form with a keyboard only

Before shipping, leave the mouse alone and run through it:

  1. Tab lands on Email, then Password, then Show, then Sign in, with no hidden stops in between.
  2. Submit empty: focus jumps to Email, and the screen reader reads the label, "invalid" and "Enter your email address."
  3. Fill the email and submit with no password: focus goes to Password.
  4. Fill both: the button shows "Signing in…" and is disabled while the request runs.
  5. Wrong credentials: the form-level message is announced once and focus moves back to Password.
  6. Space or Enter on Show toggles the password without submitting.
  7. Turn on reduce motion in your OS and confirm the form doesn't move.

On macOS, VoiceOver (Cmd+F5) with Safari is a fast check. On Windows, NVDA is free and works with Chrome and Firefox.

Adding more animation without breaking access

Shy Eyes login: a cartoon character covers its eyes while the user types a password

Once these basics hold, you can add character without hurting anyone. A login where a character covers its eyes while you type the password is a good example: the animation is decoration, and the form under it still follows every rule above. I built one like that, the Shy Eyes login, and a spotlight version built around a desk lamp, the Lamp login. There are more in the animated login forms collection.

If you want something to take apart first, the free pieces come with their full source, and the open-source ones live in the qodesign-free repo.

When you paste a form like this into a Cursor or v0 project and ask for a restyle, tell it to keep the ARIA attributes. Check that aria-describedby and the live region survive the restyle, because nothing looks broken when they're gone.

Common mistakes in generated login forms

Most login forms I get back from v0 or Cursor look fine and fail the same handful of checks. The accessibility guides that rank for this topic cover labels and ARIA in general terms, but they rarely touch the password toggle, motion, or what happens with the App Router. Here is what I look for, and the fix in each case:

MistakeWhat the user getsFix
Placeholder as the only labelNo name once they start typingA visible <label htmlFor>
Error shown in red onlyNothing announced, no reason givenText message plus aria-describedby
Alert element mounted with its textSome screen readers stay silentKeep the live region mounted, change its text
Icon-only eye button"Button", with no nameVisible or sr-only text, aria-pressed
Show button without type="button"Clicking it submits the formAdd type="button"
Shake as the only error signalNothing for screen reader users, motion for people who turned it offText first, shake behind the reduced-motion query
Form remounted to replay the shakeTyped values wipedToggle a class and clear it on animationend
Missing or wrong autoCompletePassword managers guess, or skip the fieldusername and current-password

What changes with a Server Action?

Nothing about the rules. If you submit with a Server Action and React 19's useActionState, the error comes back as returned state instead of a setErrors call. Render that message into the same always-mounted live region, keep the field errors tied to their inputs with aria-describedby, and move focus in an effect after the state changes, since the action runs outside your click handler. The pending flag from useActionState is what disables the button and switches its text to "Signing in…". The form markup stays a client component; the action itself can live in a server file.

I make animated React components at qodesign, including a few login forms like the ones above.

FAQ

Is a show/hide password button accessible?

Yes, if it is a real button with a text name such as "Show password", it has type="button" so it doesn't submit, and it exposes its state with aria-pressed. An icon with no text is the version that fails, because a screen reader only hears "button".

Should form errors go in an aria-live region or use aria-describedby?

Use both, for different jobs. Field errors belong to their input through aria-describedby, so they are read when focus moves there. A form-level failure like wrong credentials goes in one live region that is already in the page, so it is announced once without the user moving.

Can I use aria-live with React Hook Form errors?

You can, but don't wrap the whole error list in one live region, or the screen reader repeats every message on each change. Tie each field error to its input with aria-describedby, move focus to the first invalid field, and keep aria-live for the single form-level message.

How do I show and hide a password in React?

Keep a boolean in state and switch the input's type between "password" and "text". Put the toggle right after the input in the tab order and give it a clear label, so keyboard and screen reader users can find it and know what it does.