React

How to build an accessible login screen

An accessible login screen needs a visible label per field, an error tied to its input and one submit button. Start from a block, then add the live region.

Key points

  • A visible label, not a placeholder: the placeholder stays an example.
  • An error with aria-describedby and aria-invalid, announced in a live region.
  • The ready block covers the look, not the announcement: you add that part.

An accessible login screen is a form with a visible label for every field, an error tied to the field that caused it and one submit button. Everything else is added later, without touching that base.

Almost every login that fails an accessibility test fails in the same place: the placeholder acts as the label, and the error message appears next to the field without being announced.

Four non-negotiable elements

  • A visible label whose htmlFor matches the input id; the placeholder stays an example.
  • An error with aria-describedby and aria-invalid, not just red text.
  • One type=submit button; “forgot password” stays a link.
  • A visible focus ring and natural tab order: no positive tabIndex.
  • The error announced in a live region, because a screen reader user cannot see the red.

Password managers read autoComplete: email on one field and current-password on the other, never off. Without those attributes the user copies the password by hand, and on mobile that is where they close the page.

What is in the catalogue

The block login-01 covers email, password and links; login-04 adds a cover image. Browse the login category for variants. Source: github.com/shadcn-ui/ui, MIT licence.

The minimum form that passes

npx shadcn@latest add input label button
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function LoginForm({ error }: { error?: string }) {
  return (
    
      
        Email
        
      
      Accedi
      {error ? (
        
          {error}
        
      ) : null}
    
  );
}

A concrete example: focus after the error

The form above covers the fields and links the message. What it misses is the step after the server replies: focus stays on the button and a keyboard user gets no signal that anything happened. A ref on the paragraph and a focus() call inside useEffect close the loop: the live region announces, the focus moves.

import { useEffect, useRef } from "react";

export function LoginError({ message }: { message: string }) {
  const ref = useRef(null);

  useEffect(() => {
    ref.current?.focus();
  }, [message]);

  return (
    
      {message}
    
  );
}

The tabIndex={-1} makes the paragraph reachable from code without adding it to the tab order. Without that attribute focus() does nothing on an element that is not interactive: the message stays silent.

The limitation to know

Limitation: login-01 has no live region and does not link the error to the input with aria-describedby. The block covers the look and the structure; you add the error announcement, as in the snippet above. If the server returns the error after submit, move focus to the message or the form instead of leaving it on the button.

How many blocks really exist

Five of the fifteen blocks come from shadcn/ui, and login-01 declares five registryDependencies and two files: add login-01 also writes card, field, input, label and button. Counting the dependencies before the command saves you from finding an extra component in the diff.

The decision rule

Rule: if the error comes from validation before submit, aria-describedby and aria-invalid are enough. If it comes from the server after submit, the focus also has to move to the message. When both apply, one node with role="alert" does the job: two regions on the same text announce it twice.

The mistake to avoid

Disabling the button while the request is in flight looks careful and breaks the flow: focus drops to the body, the page appears frozen, and a screen reader user loses their place. Leave the button enabled, show “sending”, and ignore a second submit with a flag.

To compare the variants, read Best login blocks 2026 and the 12-point checklist for accessible React components. If you are choosing between a ready block and a headless primitive, start from Copy-paste or headless.

Frequently asked questions

Can a placeholder replace the label?

No: the placeholder disappears as soon as the user types and gives the screen reader no stable name. Keep a visible label and use the placeholder as an example only.

How do I connect the error to the right field?

With aria-describedby pointing at the message id and aria-invalid on the field. Red text alone says nothing to someone who cannot see it.

Does a single error need a live region?

Yes, if the error appears after submit without moving focus. role=alert or aria-live=assertive announces the message as soon as it appears.

Email and password in two steps, or one form?

One form, until you have a measured reason to split them. Two steps add a loading state and another point where the user can leave.

Can the catalogue login blocks ship to production?

Yes, login-01 and login-04 are MIT. They are shadcn/ui code you copy, so maintenance and tests stay yours.