Skip to content

Command Palette

Search for a command to run...

2
All posts

Engineering

Build an Accessible Password Input with shadcn/ui

Build a React password input with shadcn/ui, a keyboard-accessible show and hide control, password-manager autocomplete, and clear validation.

SS
Sourabh Soni

A password input looks like a text field with an eye icon, but a reliable version has to do more than switch between type="password" and type="text". It needs a real label, a keyboard-operable visibility control, useful autocomplete metadata, and validation that does not disappear for screen-reader users.

shadcn/ui does not need a separate black-box Password Input component for this. You can compose its Input Group, Field, and Button primitives into a small React component whose source remains in your project.

This guide builds that component, explains the accessibility decisions, and covers the differences between sign-in, sign-up, and change-password forms.

Install the shadcn/ui primitives

Add Input Group and Field with the shadcn CLI:

pnpm dlx shadcn@latest add input-group field

Use your project's package runner in place of pnpm when necessary. Input Group installs its Input and Button dependencies and provides InputGroupInput, InputGroupAddon, and InputGroupButton for controls that share one visual field.

Do not place a raw Button over an Input with absolute positioning. The current shadcn/ui Input Group already owns the border, focus ring, addon spacing, and button composition. Keep the addon after the input in the DOM and use align="inline-end" to position it visually.

Build the password input

Create a client component because password visibility is local React state:

"use client";
 
import * as React from "react";
import { EyeIcon, EyeOffIcon } from "lucide-react";
 
import {
  Field,
  FieldDescription,
  FieldLabel,
} from "@/components/ui/field";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group";
 
export function PasswordField() {
  const [isVisible, setIsVisible] = React.useState(false);
  const inputId = React.useId();
  const descriptionId = `${inputId}-description`;
 
  return (
    <Field>
      <FieldLabel htmlFor={inputId}>Password</FieldLabel>
      <InputGroup>
        <InputGroupInput
          aria-describedby={descriptionId}
          autoComplete="current-password"
          id={inputId}
          name="password"
          required
          type={isVisible ? "text" : "password"}
        />
        <InputGroupAddon align="inline-end">
          <InputGroupButton
            aria-controls={inputId}
            aria-label={isVisible ? "Hide password" : "Show password"}
            onClick={() => setIsVisible((visible) => !visible)}
            size="icon-xs"
            type="button"
          >
            {isVisible ? (
              <EyeOffIcon aria-hidden="true" />
            ) : (
              <EyeIcon aria-hidden="true" />
            )}
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
      <FieldDescription id={descriptionId}>
        Use the password for your existing account.
      </FieldDescription>
    </Field>
  );
}

The button has type="button" so it never submits the surrounding form. Its accessible name describes the next action and changes between “Show password” and “Hide password.” The icon is decorative because the button already has a text alternative.

aria-controls identifies the input affected by the button. You do not need aria-expanded: the control changes how the same value is rendered rather than expanding or collapsing a separate region.

Another valid pattern is a stable accessible name such as “Show password” with aria-pressed={isVisible}. If you use that toggle-button pattern, keep the name stable so the pressed state has a consistent meaning. Do not combine a changing action label with an unexplained pressed state.

Keep the native password behavior

The browser's native password input already provides important behavior: obscured text, password-manager integration, selection, paste, and platform keyboard support. Preserve it by changing only the type attribute when the user explicitly asks to reveal the value.

The visibility state is presentation state, not form state. Do not copy the password into a second React state variable just to reveal it, and do not render a second text input on top of the password input. One field avoids duplicate form values, focus jumps, and selection loss.

Showing a password does not decrypt or otherwise transform it. It only asks the browser to render the current value as text. The value still needs the same transport, storage, and server-side protections whether it is visible or obscured.

Use the right autocomplete value

Autocomplete is part of the component's behavior, not an optional SEO-style attribute. It tells browsers and password managers what credential belongs in the field.

Use autoComplete="current-password" when a person is signing in or confirming an existing password:

<InputGroupInput
  autoComplete="current-password"
  name="password"
  type="password"
/>

Use autoComplete="new-password" for a new account, password reset, or new password field:

<InputGroupInput
  autoComplete="new-password"
  name="new-password"
  type="password"
/>

The MDN password input reference documents both values. current-password lets a password manager fill an existing credential, while new-password can allow it to suggest and save a new one.

Pair the password field with a correctly named username or email field. Avoid autoComplete="off" as a security shortcut; browsers and password managers may ignore it, and blocking generated or stored passwords can encourage weaker credentials.

Add validation without losing accessibility

Client-side validation can improve feedback, but the server must remain the authority. When a password field is invalid, expose the same state to both the shadcn Field and the native input:

<Field data-invalid={hasError || undefined}>
  <FieldLabel htmlFor={inputId}>Password</FieldLabel>
  <InputGroup>
    <InputGroupInput
      aria-describedby={errorId}
      aria-invalid={hasError || undefined}
      autoComplete="current-password"
      id={inputId}
      name="password"
      type="password"
    />
  </InputGroup>
  {hasError ? (
    <FieldError id={errorId}>Enter your password.</FieldError>
  ) : null}
</Field>

data-invalid lets Field style its label and message. aria-invalid exposes the state on the actual form control. aria-describedby connects the input to the visible error text.

Do not return “email exists but password is wrong” on sign-in forms if that would reveal which accounts are registered. A neutral message such as “Email or password is incorrect” avoids account enumeration while still telling the person what to retry.

For a new password, show requirements before submission and keep them consistent with the server. A strength meter can be helpful, but it should not replace clear requirements or reject long passphrases because of an arbitrary maximum inherited from a design.

Preserve paste and password managers

Do not block paste into a password input. Paste is how many people transfer a generated password from a password manager, and preventing it does not stop an attacker who can already run code on the page.

Avoid these common implementation mistakes:

  • Clearing the field when the visibility button is pressed.
  • Calling preventDefault() on password paste events.
  • Replacing the input after every keystroke with a changing React key.
  • Storing the password in localStorage, analytics events, or debug logs.
  • Trimming or normalizing a password unless the product's documented password policy explicitly requires it.
  • Using a clickable icon without a native Button and accessible name.

The show and hide control should work with a pointer, Tab, Enter, and Space. A native button provides those keyboard interactions without custom event handlers.

Handle sign-up and confirmation fields

A sign-up form often has a password and confirm-password field. Give each field a distinct label and name. Both can use autoComplete="new-password", but each visibility button must control only its own input.

Do not use one shared isVisible state unless the interface explicitly offers one control that reveals both values and labels that behavior clearly. Independent controls are usually less surprising.

Validate the confirmation after the person has had a chance to enter it. An immediate “passwords do not match” error on the first character is technically true but not useful. Validate on blur, after submission, or once both fields contain a meaningful value.

Test the finished password field

Before shipping, verify the interaction rather than only its appearance:

  1. Tab from the input to the visibility button and activate it with both Enter and Space.
  2. Confirm the button name changes between “Show password” and “Hide password.”
  3. Confirm revealing the password does not clear its value or submit the form.
  4. Test browser autofill and at least one password manager on sign-in and sign-up forms.
  5. Paste a long generated password and confirm it is not silently truncated.
  6. Trigger validation and confirm the label, field, and error message remain connected.
  7. Zoom the page and test touch targets on a narrow mobile viewport.
  8. Confirm the password never appears in analytics payloads, console output, or persisted client storage.

A small component is enough

An accessible React password input does not need a specialized dependency. The native input should continue owning the credential, while shadcn/ui provides the field structure, focus treatment, and trailing action.

Start with the component above, choose the correct autocomplete value, and keep validation aligned with the server. If you are deciding whether to compose an existing primitive or search a community registry, the guide to finding missing shadcn components safely explains how to evaluate both options. You can also browse Tent UI's React components for source-owned interaction patterns that go beyond the common form primitives.

Command Palette

Search for a command to run...