# Accessible Login Form in React: Labels, Errors, Motion

> 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.

- URL: https://qodesign.dev/blog/accessible-animated-login-form-react
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-10
- Updated: 2026-10-20
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](https://react.dev/reference/react/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](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete).
- `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.

## 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](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/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](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions).

Write messages that say how to fix the problem. "Enter an email like name@example.com" 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](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/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](/previews/shy-eyes-login.jpg)

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](/components/shy-eyes-login), and a spotlight version built around a desk lamp, the [Lamp login](/components/lamp-login). There are more in the [animated login forms](/ui/animated-login-forms) collection.

If you want something to take apart first, the [free pieces](/free) come with their full source, and the open-source ones live in the [qodesign-free repo](https://github.com/teodoravadani/qodesign-free).

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:

| Mistake | What the user gets | Fix |
| --- | --- | --- |
| Placeholder as the only label | No name once they start typing | A visible `<label htmlFor>` |
| Error shown in red only | Nothing announced, no reason given | Text message plus `aria-describedby` |
| Alert element mounted with its text | Some screen readers stay silent | Keep the live region mounted, change its text |
| Icon-only eye button | "Button", with no name | Visible or `sr-only` text, `aria-pressed` |
| Show button without `type="button"` | Clicking it submits the form | Add `type="button"` |
| Shake as the only error signal | Nothing for screen reader users, motion for people who turned it off | Text first, shake behind the reduced-motion query |
| Form remounted to replay the shake | Typed values wiped | Toggle a class and clear it on `animationend` |
| Missing or wrong `autoComplete` | Password managers guess, or skip the field | `username` 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.
